Promote active screen shares

This commit is contained in:
codex
2026-06-09 22:54:56 +02:00
parent 923b85d5d3
commit 9f67be21fa
4 changed files with 219 additions and 24 deletions
+49 -3
View File
@@ -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 ()
+114 -20
View File
@@ -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);
+20
View File
@@ -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%;
+36 -1
View File
@@ -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);