Promote active screen shares
This commit is contained in:
+49
-3
@@ -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
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user