From deab4dd4a0d5ba82fd8bb0a102a95a353be4e0bf Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 23 Feb 2026 05:45:33 +0100 Subject: [PATCH] feat: Implement dynamic video quality selection and mid-stream source switching, improve viewer connection handling by closing old connections, and optimize broadcaster notifications. --- client/index.html | 9 +++++ client/renderer.js | 88 +++++++++++++++++++++++++++++++++++++++++++--- public/viewer.js | 5 +++ server.js | 6 ++-- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/client/index.html b/client/index.html index 2368912..d3c6c98 100644 --- a/client/index.html +++ b/client/index.html @@ -267,6 +267,15 @@ + + +
diff --git a/client/renderer.js b/client/renderer.js index 39d8993..85feb28 100644 --- a/client/renderer.js +++ b/client/renderer.js @@ -9,6 +9,7 @@ const localVideo = document.getElementById('localVideo'); const statusText = document.getElementById('statusText'); const statsPanel = document.getElementById('statsPanel'); const viewerCountDiv = document.getElementById('viewerCount'); +const qualitySelect = document.getElementById('qualitySelect'); function updateViewerCount() { if (viewerCountDiv) { @@ -31,9 +32,61 @@ const config = { ] }; -// 1. Get Desktop Sources from Main Process and populate raw select tags -// Also enumerate native audio devices from navigator! +// 1. Get Desktop Sources / Switch Video Source Mid-Stream getSourcesBtn.addEventListener('click', async () => { + // --- Mid-Stream Video Source Switching --- + if (activeStream) { + try { + // On Wayland, the compositor limits concurrent ScreenCast sessions. + // We MUST stop the old session BEFORE requesting a new one. + // Stop ALL video tracks to ensure the old PipeWire session is fully released. + activeStream.getVideoTracks().forEach(t => { + t.onended = null; + t.stop(); + }); + + // Give the compositor time to tear down the old ScreenCast session + await new Promise(r => setTimeout(r, 1000)); + + // Now request a new source — this opens the Wayland portal + const newStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { mandatory: { chromeMediaSource: 'desktop' } } + }); + + const newVideoTrack = newStream.getVideoTracks()[0]; + if (!newVideoTrack) return; + + await newVideoTrack.applyConstraints({ frameRate: { ideal: 60 } }); + + // Swap the track in the active stream + if (activeStream.getVideoTracks()[0]) { + activeStream.removeTrack(activeStream.getVideoTracks()[0]); + } + activeStream.addTrack(newVideoTrack); + + // Hot-swap on all peer connections without renegotiation + Object.values(peerConnections).forEach(pc => { + const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video'); + if (sender) { + sender.replaceTrack(newVideoTrack).catch(e => console.error("replaceTrack error:", e)); + } + }); + + // Update local preview + localVideo.srcObject = activeStream; + + // Re-attach onended to auto-stop if the window closes + newVideoTrack.onended = stopSharing; + } catch (e) { + console.error("Failed to switch video source:", e); + // If switching failed, stop broadcast since we already killed the old track + stopSharing(); + } + return; + } + + // --- Normal Source Selection (when not broadcasting) --- sourcesGrid.innerHTML = '
Loading sources...
'; startBtn.disabled = true; selectedVideoSourceId = null; @@ -229,7 +282,8 @@ startBtn.addEventListener('click', async () => { } const videoTrack = stream.getVideoTracks()[0]; - if (videoTrack) await videoTrack.applyConstraints({ frameRate: { ideal: 60 } }); + const [, targetFps] = (qualitySelect.value || '8000000|60').split('|'); + if (videoTrack) await videoTrack.applyConstraints({ frameRate: { ideal: parseInt(targetFps) } }); // Add audio if requested (virtual mic capture does NOT trigger a Wayland portal) if (targetAppName && targetAppName !== 'none') { @@ -281,10 +335,11 @@ function connectAndBroadcast(url, password) { activeStream.getTracks().forEach(track => { const sender = peerConnection.addTrack(track, activeStream); + const [targetBitrate] = (qualitySelect.value || '8000000|60').split('|'); if (track.kind === 'video') { const params = sender.getParameters(); if (!params.encodings) params.encodings = [{}]; - params.encodings[0].maxBitrate = 10000000; + params.encodings[0].maxBitrate = parseInt(targetBitrate); sender.setParameters(params).catch(e => console.error(e)); } else if (track.kind === 'audio') { const params = sender.getParameters(); @@ -437,6 +492,31 @@ audioSelect.addEventListener('change', async () => { } }); +// --- Dynamic Quality Switching --- +qualitySelect.addEventListener('change', async () => { + if (!activeStream) return; + + const [targetBitrate, targetFps] = qualitySelect.value.split('|'); + + // Update frame rate on the video track + const videoTrack = activeStream.getVideoTracks()[0]; + if (videoTrack) { + await videoTrack.applyConstraints({ frameRate: { ideal: parseInt(targetFps) } }).catch(e => console.error(e)); + } + + // Update bitrate on all existing peer connections + Object.values(peerConnections).forEach(pc => { + pc.getSenders().forEach(sender => { + if (sender.track && sender.track.kind === 'video') { + const params = sender.getParameters(); + if (!params.encodings) params.encodings = [{}]; + params.encodings[0].maxBitrate = parseInt(targetBitrate); + sender.setParameters(params).catch(e => console.error(e)); + } + }); + }); +}); + // --- Stats Monitoring Loop --- let lastBytesSent = 0; let lastTimestamp = 0; diff --git a/public/viewer.js b/public/viewer.js index e7f304a..3228b5e 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -15,6 +15,11 @@ const config = { }; socket.on('offer', (id, description) => { + // Close any existing connection before creating a new one + if (peerConnection) { + peerConnection.close(); + peerConnection = null; + } peerConnection = new RTCPeerConnection(config); peerConnection.ontrack = event => { diff --git a/server.js b/server.js index 7d87abd..2613423 100644 --- a/server.js +++ b/server.js @@ -24,9 +24,11 @@ io.on("connection", (socket) => { socket.broadcast.emit("broadcaster"); }); - // When a viewer joins + // When a viewer joins — notify ONLY the broadcaster, not all sockets socket.on("viewer", () => { - socket.broadcast.emit("viewer", socket.id); + if (broadcasterSocketId) { + socket.to(broadcasterSocketId).emit("viewer", socket.id); + } }); // WebRTC Signaling