commit 720e947e8c69d4b197280a26542cfb93b1cb7e2f Author: codex Date: Tue Jun 9 21:02:19 2026 +0200 Build video call prototype layout diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3341cd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.git-kkkmeet/ diff --git a/kkkmeet-server.el b/kkkmeet-server.el new file mode 100644 index 0000000..a825140 --- /dev/null +++ b/kkkmeet-server.el @@ -0,0 +1,345 @@ +;;; kkkmeet-server.el --- Tiny WebRTC signaling server -*- lexical-binding: t; -*- + +;; Run from a shell: +;; emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch +;; +;; Or from Emacs: +;; M-x kkkmeet-server-start + +(require 'cl-lib) +(require 'json) +(require 'subr-x) +(require 'url-util) + +(defgroup kkkmeet-server nil + "Small static file and WebRTC signaling server for kkkmeet." + :group 'applications) + +(defcustom kkkmeet-server-host "127.0.0.1" + "Host address for `kkkmeet-server-start'." + :type 'string) + +(defcustom kkkmeet-server-port 3000 + "Port for `kkkmeet-server-start'." + :type 'integer) + +(defcustom kkkmeet-server-public-dir + (expand-file-name "public" default-directory) + "Directory containing the browser client." + :type 'directory) + +(defvar kkkmeet-server--process nil) +(defvar kkkmeet-server--rooms (make-hash-table :test 'equal)) +(defconst kkkmeet-server--default-room-id "main") + +(defconst kkkmeet-server--mime-types + '(("html" . "text/html; charset=utf-8") + ("css" . "text/css; charset=utf-8") + ("js" . "text/javascript; charset=utf-8") + ("json" . "application/json; charset=utf-8") + ("svg" . "image/svg+xml"))) + +(defun kkkmeet-server--uuid () + "Return a UUID-like random peer id." + (let ((hex (md5 (format "%s-%s-%s" (current-time) (random) (emacs-pid))))) + (format "%s-%s-%s-%s-%s" + (substring hex 0 8) + (substring hex 8 12) + (substring hex 12 16) + (substring hex 16 20) + (substring hex 20 32)))) + +(defun kkkmeet-server--room (room-id) + "Return room hash table for ROOM-ID, creating it when needed." + (or (gethash room-id kkkmeet-server--rooms) + (puthash room-id (make-hash-table :test 'equal) kkkmeet-server--rooms))) + +(defun kkkmeet-server--hash-keys (table) + "Return keys from hash TABLE." + (let (keys) + (maphash (lambda (key _value) (push key keys)) table) + (nreverse keys))) + +(defun kkkmeet-server--room-peers (table) + "Return peer objects from room TABLE." + (let (peers) + (maphash + (lambda (peer-id process) + (push `((id . ,peer-id) + (name . ,(or (process-get process :kkkmeet-name) "Guest"))) + peers)) + table) + (vconcat (nreverse peers)))) + +(defun kkkmeet-server--normalize-name (name) + "Return a display-safe NAME." + (let ((normalized (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " (or name ""))))) + (if (string-empty-p normalized) + "Guest" + (substring normalized 0 (min 48 (length normalized)))))) + +(defun kkkmeet-server--json (value) + "Encode VALUE as JSON." + (let ((json-array-type 'list) + (json-object-type 'alist) + (json-false :false)) + (json-encode value))) + +(defun kkkmeet-server--send-response (process status headers &optional body keep-open) + "Send HTTP response to PROCESS with STATUS, HEADERS, and BODY. +When KEEP-OPEN is nil, close PROCESS after writing." + (let* ((reason (pcase status + (200 "OK") + (202 "Accepted") + (400 "Bad Request") + (403 "Forbidden") + (404 "Not Found") + (405 "Method Not Allowed") + (_ "Internal Server Error"))) + (payload (or body "")) + (base-headers `(("Connection" . ,(if keep-open "keep-alive" "close")) + ,@headers)) + (full-headers (if keep-open + base-headers + `(("Content-Length" . ,(number-to-string (string-bytes payload))) + ,@base-headers)))) + (process-send-string process (format "HTTP/1.1 %s %s\r\n" status reason)) + (dolist (header full-headers) + (process-send-string process (format "%s: %s\r\n" (car header) (cdr header)))) + (process-send-string process "\r\n") + (when body + (process-send-string process payload)) + (unless keep-open + (delete-process process)))) + +(defun kkkmeet-server--send-json (process status value) + "Send JSON VALUE to PROCESS." + (kkkmeet-server--send-response + process status + '(("Content-Type" . "application/json; charset=utf-8") + ("Cache-Control" . "no-store")) + (kkkmeet-server--json value))) + +(defun kkkmeet-server--send-sse (process value) + "Send VALUE as a server-sent event to PROCESS." + (when (process-live-p process) + (process-send-string process (format "data: %s\n\n" (kkkmeet-server--json value))))) + +(defun kkkmeet-server--broadcast (room-id sender-id value) + "Broadcast VALUE in ROOM-ID to everyone except SENDER-ID." + (let ((room (gethash room-id kkkmeet-server--rooms))) + (when room + (maphash + (lambda (peer-id process) + (unless (equal peer-id sender-id) + (kkkmeet-server--send-sse process value))) + room)))) + +(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))) + (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))) + (when (= (hash-table-count room) 0) + (remhash room-id kkkmeet-server--rooms))))))) + +(defun kkkmeet-server--parse-request (raw) + "Parse RAW HTTP request into an alist." + (let* ((split (string-match-p "\r\n\r\n" raw)) + (head (substring raw 0 split)) + (body (substring raw (+ split 4))) + (lines (split-string head "\r\n")) + (request-line (split-string (car lines) " ")) + (headers (make-hash-table :test 'equal))) + (dolist (line (cdr lines)) + (when (string-match "\\`\\([^:]+\\):[ \t]*\\(.*\\)\\'" line) + (puthash (downcase (match-string 1 line)) (match-string 2 line) headers))) + `((method . ,(nth 0 request-line)) + (target . ,(nth 1 request-line)) + (path . ,(car (split-string (nth 1 request-line) "?"))) + (headers . ,headers) + (body . ,body)))) + +(defun kkkmeet-server--request-complete-p (raw) + "Return non-nil when RAW contains a complete HTTP request." + (when-let ((split (string-match-p "\r\n\r\n" raw))) + (let* ((head (substring raw 0 split)) + (headers (make-hash-table :test 'equal))) + (dolist (line (cdr (split-string head "\r\n"))) + (when (string-match "\\`\\([^:]+\\):[ \t]*\\(.*\\)\\'" line) + (puthash (downcase (match-string 1 line)) (match-string 2 line) headers))) + (let ((length (string-to-number (or (gethash "content-length" headers) "0")))) + (>= (string-bytes (substring raw (+ split 4))) length))))) + +(defun kkkmeet-server--safe-static-path (url-path) + "Return a safe static file path for URL-PATH, or nil." + (let* ((relative (if (equal url-path "/") "/index.html" url-path)) + (decoded (url-unhex-string relative)) + (full-path (expand-file-name (string-remove-prefix "/" decoded) + kkkmeet-server-public-dir)) + (root (file-name-as-directory (expand-file-name kkkmeet-server-public-dir)))) + (when (string-prefix-p root full-path) + full-path))) + +(defun kkkmeet-server--serve-static (process method path) + "Serve static file PATH to PROCESS for METHOD." + (let ((file-path (kkkmeet-server--safe-static-path path))) + (cond + ((not file-path) + (kkkmeet-server--send-response process 403 nil "Forbidden")) + ((not (file-regular-p file-path)) + (kkkmeet-server--send-response process 404 nil "Not found")) + (t + (let* ((extension (file-name-extension file-path)) + (mime (or (cdr (assoc extension kkkmeet-server--mime-types)) + "application/octet-stream")) + (body (unless (equal method "HEAD") + (with-temp-buffer + (set-buffer-multibyte nil) + (insert-file-contents-literally file-path) + (buffer-string))))) + (kkkmeet-server--send-response + process 200 + `(("Content-Type" . ,mime) + ("Cache-Control" . "no-store")) + body)))))) + +(defun kkkmeet-server--query-param (target name) + "Return query parameter NAME from request TARGET." + (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) + "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))) + (process-put process :kkkmeet-room-id safe-room-id) + (process-put process :kkkmeet-peer-id peer-id) + (process-put process :kkkmeet-name safe-name) + (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) + (peers . ,peers))) + (kkkmeet-server--broadcast + safe-room-id peer-id `((type . "peer-joined") + (peerId . ,peer-id) + (name . ,safe-name))))) + +(defun kkkmeet-server--handle-signal (process room-id body) + "Handle signaling BODY from PROCESS for ROOM-ID." + (condition-case error + (let* ((json-object-type 'alist) + (payload (json-read-from-string body)) + (from (alist-get 'from payload)) + (to (alist-get 'to payload)) + (type (alist-get 'type payload)) + (target (gethash to (gethash room-id kkkmeet-server--rooms)))) + (if (and from to type) + (progn + (when target + (kkkmeet-server--send-sse target payload)) + (kkkmeet-server--send-json process 202 '((ok . t)))) + (kkkmeet-server--send-json + process 400 '((error . "Signal messages require from, to, and type."))))) + (error + (kkkmeet-server--send-json process 400 `((error . ,(error-message-string error))))))) + +(defun kkkmeet-server--handle-request (process request) + "Route REQUEST for PROCESS." + (let ((method (alist-get 'method request)) + (target (alist-get 'target request)) + (path (alist-get 'path request)) + (body (alist-get 'body request))) + (cond + ((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"))) + ((and (equal method "POST") (string-prefix-p "/signal/" path)) + (kkkmeet-server--handle-signal + process (url-unhex-string (string-remove-prefix "/signal/" path)) body)) + ((member method '("GET" "HEAD")) + (kkkmeet-server--serve-static process method path)) + (t + (kkkmeet-server--send-response process 405 nil "Method not allowed"))))) + +(defun kkkmeet-server--filter (process chunk) + "Accumulate request CHUNK for PROCESS and respond when complete." + (let ((raw (concat (or (process-get process :kkkmeet-raw-request) "") chunk))) + (process-put process :kkkmeet-raw-request raw) + (when (kkkmeet-server--request-complete-p raw) + (process-put process :kkkmeet-raw-request nil) + (condition-case error + (kkkmeet-server--handle-request process (kkkmeet-server--parse-request raw)) + (error + (kkkmeet-server--send-json process 500 `((error . ,(error-message-string error))))))))) + +(defun kkkmeet-server--sentinel (process _event) + "Clean up PROCESS when a connection closes." + (unless (process-live-p process) + (kkkmeet-server--remove-peer process))) + +;;;###autoload +(defun kkkmeet-server-start (&optional port host) + "Start kkkmeet server on PORT and HOST." + (interactive) + (kkkmeet-server-stop) + (setq kkkmeet-server--rooms (make-hash-table :test 'equal)) + (setq kkkmeet-server--process + (make-network-process + :name "kkkmeet-server" + :server t + :host (or host kkkmeet-server-host) + :service (or port kkkmeet-server-port) + :coding 'binary + :filter #'kkkmeet-server--filter + :sentinel #'kkkmeet-server--sentinel)) + (message "kkkmeet is running at http://%s:%s" + (or host kkkmeet-server-host) + (or port kkkmeet-server-port))) + +;;;###autoload +(defun kkkmeet-server-stop () + "Stop the kkkmeet server and all active SSE clients." + (interactive) + (when (process-live-p kkkmeet-server--process) + (delete-process kkkmeet-server--process)) + (maphash + (lambda (_room-id room) + (maphash (lambda (_peer-id process) + (when (process-live-p process) + (delete-process process))) + room)) + kkkmeet-server--rooms) + (setq kkkmeet-server--process nil) + (setq kkkmeet-server--rooms (make-hash-table :test 'equal))) + +;;;###autoload +(defun kkkmeet-server-run-batch () + "Run kkkmeet server forever for `emacs --batch'." + (let ((port (or (and (getenv "PORT") (string-to-number (getenv "PORT"))) + kkkmeet-server-port)) + (host (or (getenv "HOST") kkkmeet-server-host))) + (kkkmeet-server-start port host) + (while t + (accept-process-output nil 1)))) + +(provide 'kkkmeet-server) +;;; kkkmeet-server.el ends here diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb1ae2c --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "kkkmeet", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "start:elisp": "emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch" + }, + "engines": { + "node": ">=20" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..8f8ffe0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,282 @@ +const localVideo = document.querySelector("#local-video"); +const localNameLabel = document.querySelector("#local-name"); +const remoteGrid = document.querySelector("#remote-grid"); +const remoteTemplate = document.querySelector("#remote-template"); +const nameForm = document.querySelector("#name-form"); +const nameInput = document.querySelector("#name-input"); +const statusText = document.querySelector("#status"); +const cameraButton = document.querySelector("#camera-button"); +const micButton = document.querySelector("#mic-button"); +const shareButton = document.querySelector("#share-button"); +const leaveButton = document.querySelector("#leave-button"); + +const roomId = "main"; +const nameStorageKey = "kkkmeet.displayName"; +const rtcConfig = { + iceServers: [{ urls: "stun:stun.l.google.com:19302" }] +}; + +let peerId = ""; +let displayName = ""; +let localStream; +let screenStream; +let events; +const peers = new Map(); + +function setStatus(message) { + statusText.textContent = message; +} + +function normalizeName(name) { + return name.trim().replace(/\s+/g, " ").slice(0, 48); +} + +function setDisplayName(nextName) { + displayName = normalizeName(nextName); + localStorage.setItem(nameStorageKey, displayName); + nameInput.value = displayName; + localNameLabel.textContent = displayName ? `${displayName} (you)` : "You"; +} + +function labelForPeer(peer) { + return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim(); +} + +async function ensureLocalStream() { + if (localStream) return localStream; + + localStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: { width: { ideal: 1280 }, height: { ideal: 720 } } + }); + localVideo.srcObject = localStream; + return localStream; +} + +function updateRemoteLabel(id, name) { + const label = document.querySelector(`[data-peer-id="${id}"] span`); + if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`; +} + +function createRemoteTile(peer) { + const id = typeof peer === "string" ? peer : peer.id; + const fragment = remoteTemplate.content.cloneNode(true); + const tile = fragment.querySelector(".tile"); + const video = fragment.querySelector("video"); + const label = fragment.querySelector("span"); + tile.dataset.peerId = id; + label.textContent = labelForPeer(typeof peer === "string" ? { id } : peer); + remoteGrid.append(tile); + return { tile, video }; +} + +function removeRemoteTile(id) { + document.querySelector(`[data-peer-id="${id}"]`)?.remove(); +} + +async function sendSignal(to, payload) { + await fetch(`/signal/${encodeURIComponent(roomId)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, from: peerId, to, name: displayName }) + }); +} + +function createPeerConnection(remotePeer) { + const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; + if (peers.has(remotePeerId)) { + updateRemoteLabel(remotePeerId, remotePeer.name); + return peers.get(remotePeerId).connection; + } + + const connection = new RTCPeerConnection(rtcConfig); + const remoteStream = new MediaStream(); + const { video } = createRemoteTile(remotePeer); + video.srcObject = remoteStream; + + localStream.getTracks().forEach((track) => { + connection.addTrack(track, localStream); + }); + + connection.ontrack = (event) => { + event.streams[0].getTracks().forEach((track) => { + remoteStream.addTrack(track); + }); + }; + + connection.onicecandidate = (event) => { + if (event.candidate) { + sendSignal(remotePeerId, { type: "ice-candidate", candidate: event.candidate }); + } + }; + + connection.onconnectionstatechange = () => { + if (["closed", "disconnected", "failed"].includes(connection.connectionState)) { + closePeer(remotePeerId); + } + }; + + peers.set(remotePeerId, { connection, remoteStream, name: remotePeer.name }); + return connection; +} + +async function callPeer(remotePeer) { + const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; + const connection = createPeerConnection(remotePeer); + const offer = await connection.createOffer(); + await connection.setLocalDescription(offer); + await sendSignal(remotePeerId, { type: "offer", description: connection.localDescription }); +} + +async function answerPeer(message) { + const connection = createPeerConnection({ id: message.from, name: message.name }); + await connection.setRemoteDescription(message.description); + const answer = await connection.createAnswer(); + await connection.setLocalDescription(answer); + await sendSignal(message.from, { type: "answer", description: connection.localDescription }); +} + +async function handleSignal(message) { + if (message.type === "welcome") { + peerId = message.peerId; + setStatus(`Joined the call as ${displayName}. ${message.peers.length} other participant(s).`); + for (const existingPeer of message.peers) { + await callPeer(existingPeer); + } + return; + } + + if (message.type === "peer-joined") { + updateRemoteLabel(message.peerId, message.name); + setStatus(`${message.name || "A participant"} joined.`); + return; + } + + if (message.type === "peer-left") { + closePeer(message.peerId); + setStatus("A participant left."); + return; + } + + if (message.type === "offer") { + await answerPeer(message); + return; + } + + const connection = peers.get(message.from)?.connection; + if (!connection) return; + + if (message.type === "answer") { + await connection.setRemoteDescription(message.description); + } + + if (message.type === "ice-candidate") { + await connection.addIceCandidate(message.candidate); + } +} + +function closePeer(remotePeerId) { + const peer = peers.get(remotePeerId); + if (!peer) return; + + peer.connection.close(); + peers.delete(remotePeerId); + removeRemoteTile(remotePeerId); +} + +async function joinCall() { + if (!displayName) { + setStatus("Enter your name to join the call."); + nameInput.focus(); + return; + } + + leaveCall(); + await ensureLocalStream(); + + const params = new URLSearchParams({ name: displayName }); + 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."); + setStatus(`Joining as ${displayName}...`); +} + +function leaveCall() { + events?.close(); + events = undefined; + peerId = ""; + for (const remotePeerId of peers.keys()) closePeer(remotePeerId); + setStatus(displayName ? `Ready as ${displayName}.` : "Enter your name to join the call."); +} + +function setTrackEnabled(kind, enabled) { + localStream?.getTracks().forEach((track) => { + if (track.kind === kind) track.enabled = enabled; + }); +} + +nameForm.addEventListener("submit", (event) => { + event.preventDefault(); + setDisplayName(nameInput.value); + joinCall().catch((error) => { + console.error(error); + setStatus("Could not start camera or join the call."); + }); +}); + +nameInput.addEventListener("change", () => { + setDisplayName(nameInput.value); + if (events) { + joinCall().catch((error) => { + console.error(error); + setStatus("Could not rejoin with the updated name."); + }); + } +}); + +cameraButton.addEventListener("click", () => { + const enabled = cameraButton.getAttribute("aria-pressed") !== "true"; + cameraButton.setAttribute("aria-pressed", String(enabled)); + setTrackEnabled("video", enabled); +}); + +micButton.addEventListener("click", () => { + const enabled = micButton.getAttribute("aria-pressed") !== "true"; + micButton.setAttribute("aria-pressed", String(enabled)); + setTrackEnabled("audio", enabled); +}); + +shareButton.addEventListener("click", async () => { + if (!localStream) return; + + screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const screenTrack = screenStream.getVideoTracks()[0]; + + for (const { connection } of peers.values()) { + const sender = connection.getSenders().find((item) => item.track?.kind === "video"); + await sender?.replaceTrack(screenTrack); + } + + localVideo.srcObject = screenStream; + screenTrack.onended = async () => { + const cameraTrack = localStream.getVideoTracks()[0]; + for (const { connection } of peers.values()) { + const sender = connection.getSenders().find((item) => item.track?.kind === "video"); + await sender?.replaceTrack(cameraTrack); + } + localVideo.srcObject = localStream; + }; +}); + +leaveButton.addEventListener("click", leaveCall); + +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 { + nameInput.focus(); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3d8f9e8 --- /dev/null +++ b/public/index.html @@ -0,0 +1,51 @@ + + + + + + kkkmeet + + + +
+
+
+

kkkmeet

+

Enter your name to join the call.

+
+
+ + +
+
+ +
+
+
Waiting for others to join
+
+
+ +
+
+ + + + +
+
+ + You +
+
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..9edb253 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,264 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #101214; + color: #f2f4f7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(46, 125, 50, 0.16), transparent 35%), + linear-gradient(315deg, rgba(3, 169, 244, 0.16), transparent 34%), + #101214; +} + +button, +input { + font: inherit; +} + +.app-shell { + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 100vh; + min-height: 100dvh; +} + +.topbar, +.controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 24px; +} + +.topbar { + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +h1, +p { + margin: 0; +} + +h1 { + font-size: 22px; + font-weight: 700; + letter-spacing: 0; +} + +#status { + margin-top: 4px; + color: #aeb7c3; + font-size: 14px; +} + +.name-form { + display: flex; + gap: 10px; +} + +input { + width: min(42vw, 260px); + height: 42px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + padding: 0 12px; +} + +button { + min-width: 92px; + height: 42px; + border: 0; + border-radius: 8px; + background: #1f8f5f; + color: white; + cursor: pointer; + font-weight: 650; +} + +button:hover { + filter: brightness(1.08); +} + +button[aria-pressed="false"] { + background: #4a535f; +} + +button.danger { + background: #c73838; +} + +.stage { + display: grid; + min-height: 0; + padding: 18px 18px 12px; +} + +.remote-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + grid-auto-rows: minmax(210px, 1fr); + gap: 14px; + align-content: stretch; + min-height: 100%; +} + +.tile { + position: relative; + overflow: hidden; + min-height: 220px; + aspect-ratio: 16 / 10; + border-radius: 8px; + background: #1b2026; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.tile video { + width: 100%; + height: 100%; + object-fit: cover; + background: #171b20; +} + +.tile span { + position: absolute; + left: 12px; + bottom: 12px; + max-width: calc(100% - 24px); + padding: 6px 9px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.56); + color: #fff; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-stage { + display: grid; + place-items: center; + min-height: min(58vh, 560px); + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: #aeb7c3; + font-size: 15px; +} + +.remote-grid:has(.tile) .empty-stage { + display: none; +} + +.controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + justify-content: initial; + border-top: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(16, 18, 20, 0.92); +} + +.control-actions { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 12px; +} + +.control-actions button { + height: 48px; + min-width: 112px; +} + +.self-preview { + position: relative; + overflow: hidden; + width: clamp(150px, 18vw, 240px); + aspect-ratio: 16 / 10; + border-radius: 8px; + background: #1b2026; + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); +} + +.self-preview video { + width: 100%; + height: 100%; + object-fit: cover; + background: #171b20; +} + +.self-preview span { + position: absolute; + left: 10px; + bottom: 10px; + max-width: calc(100% - 20px); + padding: 5px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.58); + color: #fff; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 680px) { + .topbar { + align-items: stretch; + flex-direction: column; + padding: 14px; + } + + .name-form { + width: 100%; + } + + input { + width: 100%; + } + + button { + min-width: 76px; + } + + .stage { + padding: 10px; + } + + .remote-grid { + grid-template-columns: 1fr; + grid-auto-rows: minmax(190px, 1fr); + } + + .controls { + grid-template-columns: 1fr; + gap: 12px; + padding: 12px; + } + + .control-actions { + order: 2; + } + + .control-actions button { + min-width: calc(50% - 6px); + } + + .self-preview { + justify-self: end; + width: min(42vw, 180px); + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..ed77a2b --- /dev/null +++ b/server.js @@ -0,0 +1,167 @@ +import http from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; +import { randomUUID } from "node:crypto"; + +const port = Number(process.env.PORT || 3000); +const host = process.env.HOST || "127.0.0.1"; +const publicDir = join(process.cwd(), "public"); +const rooms = new Map(); +const defaultRoomId = "main"; + +const mimeTypes = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml" +}; + +function sendJson(res, status, body) { + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store" + }); + res.end(JSON.stringify(body)); +} + +function getRoom(roomId) { + if (!rooms.has(roomId)) { + rooms.set(roomId, new Map()); + } + return rooms.get(roomId); +} + +function normalizeName(name) { + const normalized = String(name || "").trim().replace(/\s+/g, " "); + return normalized.slice(0, 48) || "Guest"; +} + +function broadcast(roomId, senderId, event) { + const room = rooms.get(roomId); + if (!room) return; + + for (const [peerId, peer] of room) { + if (peerId === senderId) continue; + peer.res.write(`data: ${JSON.stringify(event)}\n\n`); + } +} + +function removePeer(roomId, peerId) { + const room = rooms.get(roomId); + if (!room) return; + + room.delete(peerId); + broadcast(roomId, peerId, { type: "peer-left", peerId }); + + if (room.size === 0) { + rooms.delete(roomId); + } +} + +async function parseJson(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +async function serveStatic(req, res) { + const url = new URL(req.url, `http://${req.headers.host}`); + const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname; + const safePath = normalize(decodeURIComponent(requestedPath)).replace(/^(\.\.[/\\])+/, ""); + const filePath = join(publicDir, safePath); + + if (!filePath.startsWith(publicDir)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + try { + const data = await readFile(filePath); + res.writeHead(200, { + "content-type": mimeTypes[extname(filePath)] || "application/octet-stream", + "cache-control": "no-store" + }); + res.end(req.method === "HEAD" ? undefined : data); + } catch { + res.writeHead(404); + res.end("Not found"); + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + try { + if (req.method === "GET" && url.pathname.startsWith("/events/")) { + const roomId = url.pathname.split("/").at(-1) || defaultRoomId; + const displayName = normalizeName(url.searchParams.get("name")); + const peerId = randomUUID(); + const room = getRoom(roomId); + const existingPeers = [...room].map(([id, peer]) => ({ id, name: peer.name })); + + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no" + }); + + room.set(peerId, { res, name: displayName }); + res.write( + `data: ${JSON.stringify({ + type: "welcome", + peerId, + name: displayName, + peers: existingPeers + })}\n\n` + ); + broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName }); + + const heartbeat = setInterval(() => { + res.write(": heartbeat\n\n"); + }, 15000); + + req.on("close", () => { + clearInterval(heartbeat); + removePeer(roomId, peerId); + }); + return; + } + + if (req.method === "POST" && url.pathname.startsWith("/signal/")) { + const roomId = url.pathname.split("/").at(-1); + const payload = await parseJson(req); + + if (!payload.from || !payload.to || !payload.type) { + sendJson(res, 400, { error: "Signal messages require from, to, and type." }); + return; + } + + const room = rooms.get(roomId); + const target = room?.get(payload.to); + if (target) { + target.res.write(`data: ${JSON.stringify(payload)}\n\n`); + } + + sendJson(res, 202, { ok: true }); + return; + } + + if (req.method === "GET" || req.method === "HEAD") { + await serveStatic(req, res); + return; + } + + res.writeHead(405); + res.end("Method not allowed"); + } catch (error) { + sendJson(res, 500, { error: error.message }); + } +}); + +server.listen(port, host, () => { + console.log(`kkkmeet is running at http://${host}:${port}`); +});