feat: Implement dynamic video quality selection and mid-stream source switching, improve viewer connection handling by closing old connections, and optimize broadcaster notifications.

This commit is contained in:
2026-02-23 05:45:33 +01:00
parent 0d7a51ddcd
commit deab4dd4a0
4 changed files with 102 additions and 6 deletions

View File

@@ -267,6 +267,15 @@
<label class="label">Audio Source (Microphone/Virtual Sinks)</label>
<select id="audioSelect"></select>
<label class="label">Video Quality</label>
<select id="qualitySelect">
<option value="1000000|30">Low (1 Mbps / 30fps)</option>
<option value="4000000|30">Medium (4 Mbps / 30fps)</option>
<option value="8000000|60" selected>High (8 Mbps / 60fps)</option>
<option value="15000000|60">Ultra (15 Mbps / 60fps)</option>
<option value="30000000|60">Max (30 Mbps / 60fps)</option>
</select>
<div style="margin-top: auto;">
<button id="startBtn" disabled style="margin-bottom: 0.5rem;">Start Broadcast</button>

View File

@@ -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 = '<div style="color:var(--text-secondary); width:100%;">Loading sources...</div>';
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;

View File

@@ -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 => {

View File

@@ -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