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