Build video call prototype layout

This commit is contained in:
codex
2026-06-09 21:02:19 +02:00
commit 720e947e8c
7 changed files with 1123 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.git-kkkmeet/
+345
View File
@@ -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
+13
View File
@@ -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"
}
}
+282
View File
@@ -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();
}
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>kkkmeet</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div>
<h1>kkkmeet</h1>
<p id="status">Enter your name to join the call.</p>
</div>
<form id="name-form" class="name-form">
<input id="name-input" autocomplete="name" maxlength="48" placeholder="Your name" required />
<button type="submit">Join</button>
</form>
</header>
<section class="stage" aria-label="Video call">
<div class="remote-grid" id="remote-grid">
<div class="empty-stage" id="empty-stage">Waiting for others to join</div>
</div>
</section>
<footer class="controls">
<div class="control-actions" aria-label="Call controls">
<button id="camera-button" type="button" aria-pressed="true">Camera</button>
<button id="mic-button" type="button" aria-pressed="true">Mic</button>
<button id="share-button" type="button">Share Screen</button>
<button id="leave-button" type="button" class="danger">Leave</button>
</div>
<article class="self-preview">
<video id="local-video" autoplay muted playsinline></video>
<span id="local-name">You</span>
</article>
</footer>
</main>
<template id="remote-template">
<article class="tile">
<video autoplay playsinline></video>
<span>Participant</span>
</article>
</template>
<script type="module" src="/app.js"></script>
</body>
</html>
+264
View File
@@ -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);
}
}
+167
View File
@@ -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}`);
});