diff --git a/kkkmeet-server.el b/kkkmeet-server.el index 959e784..d984ec1 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -32,6 +32,7 @@ (defvar kkkmeet-server--process nil) (defvar kkkmeet-server--rooms (make-hash-table :test 'equal)) (defvar kkkmeet-server--watch-states (make-hash-table :test 'equal)) +(defvar kkkmeet-server--presentation-states (make-hash-table :test 'equal)) (defconst kkkmeet-server--default-room-id "main") (defconst kkkmeet-server--mime-types @@ -179,9 +180,17 @@ When KEEP-OPEN is nil, close PROCESS after writing." (remhash peer-id room) (kkkmeet-server--broadcast room-id peer-id `((type . "peer-left") (peerId . ,peer-id))) + (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) + (sharing . :false)))) (when (= (hash-table-count room) 0) (remhash room-id kkkmeet-server--rooms) - (remhash room-id kkkmeet-server--watch-states))))))) + (remhash room-id kkkmeet-server--watch-states) + (remhash room-id kkkmeet-server--presentation-states))))))) (defun kkkmeet-server--parse-request (raw) "Parse RAW HTTP request into an alist." @@ -272,7 +281,9 @@ When KEEP-OPEN is nil, close PROCESS after writing." (name . ,safe-name) (peers . ,peers) (watchState . ,(or (gethash safe-room-id kkkmeet-server--watch-states) - :null)))) + :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) @@ -334,6 +345,36 @@ When KEEP-OPEN is nil, close PROCESS after writing." (error (kkkmeet-server--send-json process 400 `((error . ,(error-message-string error))))))) +(defun kkkmeet-server--handle-room-event (process room-id body) + "Handle shared room event BODY for ROOM-ID." + (condition-case error + (let* ((json-object-type 'alist) + (payload (json-read-from-string body)) + (from (alist-get 'from payload)) + (type (alist-get 'type payload)) + (safe-room-id (if (string-empty-p room-id) + kkkmeet-server--default-room-id + room-id))) + (if (and from (equal type "presentation-state")) + (let ((event `((type . "presentation-state") + (from . ,from) + (peerId . ,from) + (name . ,(kkkmeet-server--normalize-name + (alist-get 'name payload))) + (sharing . ,(if (alist-get 'sharing payload) t :false)) + (updatedAt . ,(floor (* 1000 (float-time))))))) + (if (alist-get 'sharing payload) + (puthash safe-room-id event kkkmeet-server--presentation-states) + (when (equal (alist-get 'peerId (gethash safe-room-id kkkmeet-server--presentation-states)) + from) + (remhash safe-room-id kkkmeet-server--presentation-states))) + (kkkmeet-server--broadcast safe-room-id from event) + (kkkmeet-server--send-json process 202 '((ok . t)))) + (kkkmeet-server--send-json + process 400 '((error . "Room events require from and type=presentation-state."))))) + (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)) @@ -352,6 +393,9 @@ When KEEP-OPEN is nil, close PROCESS after writing." ((and (equal method "POST") (string-prefix-p "/watch/" path)) (kkkmeet-server--handle-watch process (url-unhex-string (string-remove-prefix "/watch/" path)) body)) + ((and (equal method "POST") (string-prefix-p "/room-event/" path)) + (kkkmeet-server--handle-room-event + process (url-unhex-string (string-remove-prefix "/room-event/" path)) body)) ((member method '("GET" "HEAD")) (kkkmeet-server--serve-static process method path)) (t @@ -380,6 +424,7 @@ When KEEP-OPEN is nil, close PROCESS after writing." (kkkmeet-server-stop) (setq kkkmeet-server--rooms (make-hash-table :test 'equal)) (setq kkkmeet-server--watch-states (make-hash-table :test 'equal)) + (setq kkkmeet-server--presentation-states (make-hash-table :test 'equal)) (setq kkkmeet-server--process (make-network-process :name "kkkmeet-server" @@ -408,7 +453,8 @@ When KEEP-OPEN is nil, close PROCESS after writing." kkkmeet-server--rooms) (setq kkkmeet-server--process nil) (setq kkkmeet-server--rooms (make-hash-table :test 'equal)) - (setq kkkmeet-server--watch-states (make-hash-table :test 'equal))) + (setq kkkmeet-server--watch-states (make-hash-table :test 'equal)) + (setq kkkmeet-server--presentation-states (make-hash-table :test 'equal))) ;;;###autoload (defun kkkmeet-server-run-batch () diff --git a/public/app.js b/public/app.js index 282dbe0..1cb6524 100644 --- a/public/app.js +++ b/public/app.js @@ -38,6 +38,7 @@ let peerId = ""; let displayName = ""; let localStream; let screenStream; +let localPresentationTile; let events; let youtubePlayer; let youtubeReady = false; @@ -487,8 +488,43 @@ function createRemoteTile(peer) { return { tile, video }; } +function updatePresentationLayout() { + remoteGrid.classList.toggle("has-presentation", Boolean(remoteGrid.querySelector(".presentation-tile"))); +} + +function createLocalPresentationTile(stream) { + removeLocalPresentationTile(); + const fragment = remoteTemplate.content.cloneNode(true); + const tile = fragment.querySelector(".tile"); + const video = fragment.querySelector("video"); + const label = fragment.querySelector("span"); + tile.classList.add("presentation-tile", "local-presentation-tile"); + tile.dataset.localPresentation = "true"; + label.textContent = "Your screen"; + video.srcObject = stream; + remoteGrid.prepend(tile); + localPresentationTile = tile; + updatePresentationLayout(); +} + +function removeLocalPresentationTile() { + localPresentationTile?.remove(); + localPresentationTile = undefined; + updatePresentationLayout(); +} + +function setRemotePresentation(peerId, sharing, name) { + const tile = document.querySelector(`[data-peer-id="${peerId}"]`); + if (!tile) return; + + tile.classList.toggle("presentation-tile", sharing); + updateRemoteLabel(peerId, sharing ? `${name || "Participant"}'s screen` : name); + updatePresentationLayout(); +} + function removeRemoteTile(id) { document.querySelector(`[data-peer-id="${id}"]`)?.remove(); + updatePresentationLayout(); } async function sendSignal(to, payload) { @@ -499,6 +535,29 @@ async function sendSignal(to, payload) { }); } +async function sendRoomEvent(payload) { + if (!peerId) return; + + await fetch(`/room-event/${encodeURIComponent(roomId)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...payload, from: peerId, name: displayName }) + }); +} + +function outboundTracks() { + const audioTracks = localStream?.getAudioTracks() || []; + const videoTrack = screenStream?.getVideoTracks()[0] || localStream?.getVideoTracks()[0]; + return [...audioTracks, videoTrack].filter(Boolean); +} + +async function replaceOutboundVideoTrack(track) { + for (const { connection } of peers.values()) { + const sender = connection.getSenders().find((item) => item.track?.kind === "video"); + await sender?.replaceTrack(track); + } +} + function createPeerConnection(remotePeer) { const remotePeerId = typeof remotePeer === "string" ? remotePeer : remotePeer.id; if (peers.has(remotePeerId)) { @@ -511,8 +570,8 @@ function createPeerConnection(remotePeer) { const { video } = createRemoteTile(remotePeer); video.srcObject = remoteStream; - localStream.getTracks().forEach((track) => { - connection.addTrack(track, localStream); + outboundTracks().forEach((track) => { + connection.addTrack(track, track.kind === "video" && screenStream ? screenStream : localStream); }); connection.ontrack = (event) => { @@ -560,6 +619,15 @@ async function handleSignal(message) { if (message.watchState) { await applyWatchState(message.watchState); } + if (message.presentationState?.sharing) { + setTimeout(() => { + setRemotePresentation( + message.presentationState.from || message.presentationState.peerId, + true, + message.presentationState.name + ); + }, 0); + } for (const existingPeer of message.peers) { await callPeer(existingPeer); } @@ -589,6 +657,14 @@ async function handleSignal(message) { return; } + if (message.type === "presentation-state") { + setRemotePresentation(message.from, message.sharing, message.name); + setStatus(message.sharing + ? `${message.name || "Someone"} is sharing their screen.` + : `${message.name || "Someone"} stopped sharing their screen.`); + return; + } + const connection = peers.get(message.from)?.connection; if (!connection) return; @@ -718,6 +794,38 @@ async function clearYoutubePlaylist() { await sendWatchState({ videoId: "", position: 0, playing: false }); } +async function startScreenShare() { + if (!localStream || screenStream) 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 }); + + screenTrack.onended = () => { + stopScreenShare().catch((error) => { + console.error(error); + setStatus("Could not stop screen sharing cleanly."); + }); + }; +} + +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); + } + shareButton.textContent = "Share Screen"; + await sendRoomEvent({ type: "presentation-state", sharing: false }); +} + initialNameForm.addEventListener("submit", (event) => { event.preventDefault(); setDisplayName(initialNameInput.value); @@ -806,25 +914,11 @@ clearPlaylistButton.addEventListener("click", () => { 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); + if (screenStream) { + await stopScreenShare(); + } else { + await startScreenShare(); } - - 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; - screenStream = undefined; - }; }); const savedName = localStorage.getItem(nameStorageKey); diff --git a/public/styles.css b/public/styles.css index eb4dcc7..c7bcb0e 100644 --- a/public/styles.css +++ b/public/styles.css @@ -343,6 +343,12 @@ button[aria-pressed="false"] { min-height: 100%; } +.remote-grid.has-presentation { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + grid-auto-rows: minmax(130px, auto); + align-content: start; +} + .tile { position: relative; overflow: hidden; @@ -353,6 +359,20 @@ button[aria-pressed="false"] { border: 1px solid rgba(255, 255, 255, 0.1); } +.presentation-tile { + grid-column: 1 / -1; + min-height: min(58vh, 620px); + aspect-ratio: 16 / 9; +} + +.remote-grid.has-presentation .tile:not(.presentation-tile) { + min-height: 130px; +} + +.remote-grid.has-presentation .tile:not(.presentation-tile) span { + font-size: 12px; +} + .tile video { width: 100%; height: 100%; diff --git a/server.js b/server.js index 7dcc06b..bd1582d 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const host = process.env.HOST || "127.0.0.1"; const publicDir = join(process.cwd(), "public"); const rooms = new Map(); const watchStates = new Map(); +const presentationStates = new Map(); const defaultRoomId = "main"; const mimeTypes = { @@ -76,10 +77,15 @@ function removePeer(roomId, peerId) { room.delete(peerId); broadcast(roomId, peerId, { type: "peer-left", peerId }); + if (presentationStates.get(roomId)?.peerId === peerId) { + presentationStates.delete(roomId); + broadcast(roomId, peerId, { type: "presentation-state", from: peerId, sharing: false }); + } if (room.size === 0) { rooms.delete(roomId); watchStates.delete(roomId); + presentationStates.delete(roomId); } } @@ -140,7 +146,8 @@ const server = http.createServer(async (req, res) => { peerId, name: displayName, peers: existingPeers, - watchState: watchStates.get(roomId) || null + watchState: watchStates.get(roomId) || null, + presentationState: presentationStates.get(roomId) || null })}\n\n` ); broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName }); @@ -156,6 +163,34 @@ const server = http.createServer(async (req, res) => { return; } + if (req.method === "POST" && url.pathname.startsWith("/room-event/")) { + const roomId = url.pathname.split("/").at(-1) || defaultRoomId; + const payload = await parseJson(req); + + if (!payload.from || payload.type !== "presentation-state") { + sendJson(res, 400, { error: "Room events require from and type=presentation-state." }); + return; + } + + const event = { + type: "presentation-state", + from: payload.from, + name: normalizeName(payload.name), + sharing: Boolean(payload.sharing), + updatedAt: Date.now() + }; + + if (event.sharing) { + presentationStates.set(roomId, { ...event, peerId: payload.from }); + } else if (presentationStates.get(roomId)?.peerId === payload.from) { + presentationStates.delete(roomId); + } + + broadcast(roomId, payload.from, event); + sendJson(res, 202, { ok: true }); + return; + } + if (req.method === "POST" && url.pathname.startsWith("/signal/")) { const roomId = url.pathname.split("/").at(-1); const payload = await parseJson(req);