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:
@@ -267,6 +267,15 @@
|
|||||||
|
|
||||||
<label class="label">Audio Source (Microphone/Virtual Sinks)</label>
|
<label class="label">Audio Source (Microphone/Virtual Sinks)</label>
|
||||||
<select id="audioSelect"></select>
|
<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;">
|
<div style="margin-top: auto;">
|
||||||
<button id="startBtn" disabled style="margin-bottom: 0.5rem;">Start Broadcast</button>
|
<button id="startBtn" disabled style="margin-bottom: 0.5rem;">Start Broadcast</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const localVideo = document.getElementById('localVideo');
|
|||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
const statsPanel = document.getElementById('statsPanel');
|
const statsPanel = document.getElementById('statsPanel');
|
||||||
const viewerCountDiv = document.getElementById('viewerCount');
|
const viewerCountDiv = document.getElementById('viewerCount');
|
||||||
|
const qualitySelect = document.getElementById('qualitySelect');
|
||||||
|
|
||||||
function updateViewerCount() {
|
function updateViewerCount() {
|
||||||
if (viewerCountDiv) {
|
if (viewerCountDiv) {
|
||||||
@@ -31,9 +32,61 @@ const config = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Get Desktop Sources from Main Process and populate raw select tags
|
// 1. Get Desktop Sources / Switch Video Source Mid-Stream
|
||||||
// Also enumerate native audio devices from navigator!
|
|
||||||
getSourcesBtn.addEventListener('click', async () => {
|
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>';
|
sourcesGrid.innerHTML = '<div style="color:var(--text-secondary); width:100%;">Loading sources...</div>';
|
||||||
startBtn.disabled = true;
|
startBtn.disabled = true;
|
||||||
selectedVideoSourceId = null;
|
selectedVideoSourceId = null;
|
||||||
@@ -229,7 +282,8 @@ startBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const videoTrack = stream.getVideoTracks()[0];
|
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)
|
// Add audio if requested (virtual mic capture does NOT trigger a Wayland portal)
|
||||||
if (targetAppName && targetAppName !== 'none') {
|
if (targetAppName && targetAppName !== 'none') {
|
||||||
@@ -281,10 +335,11 @@ function connectAndBroadcast(url, password) {
|
|||||||
|
|
||||||
activeStream.getTracks().forEach(track => {
|
activeStream.getTracks().forEach(track => {
|
||||||
const sender = peerConnection.addTrack(track, activeStream);
|
const sender = peerConnection.addTrack(track, activeStream);
|
||||||
|
const [targetBitrate] = (qualitySelect.value || '8000000|60').split('|');
|
||||||
if (track.kind === 'video') {
|
if (track.kind === 'video') {
|
||||||
const params = sender.getParameters();
|
const params = sender.getParameters();
|
||||||
if (!params.encodings) params.encodings = [{}];
|
if (!params.encodings) params.encodings = [{}];
|
||||||
params.encodings[0].maxBitrate = 10000000;
|
params.encodings[0].maxBitrate = parseInt(targetBitrate);
|
||||||
sender.setParameters(params).catch(e => console.error(e));
|
sender.setParameters(params).catch(e => console.error(e));
|
||||||
} else if (track.kind === 'audio') {
|
} else if (track.kind === 'audio') {
|
||||||
const params = sender.getParameters();
|
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 ---
|
// --- Stats Monitoring Loop ---
|
||||||
let lastBytesSent = 0;
|
let lastBytesSent = 0;
|
||||||
let lastTimestamp = 0;
|
let lastTimestamp = 0;
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ const config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.on('offer', (id, description) => {
|
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 = new RTCPeerConnection(config);
|
||||||
|
|
||||||
peerConnection.ontrack = event => {
|
peerConnection.ontrack = event => {
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ io.on("connection", (socket) => {
|
|||||||
socket.broadcast.emit("broadcaster");
|
socket.broadcast.emit("broadcaster");
|
||||||
});
|
});
|
||||||
|
|
||||||
// When a viewer joins
|
// When a viewer joins — notify ONLY the broadcaster, not all sockets
|
||||||
socket.on("viewer", () => {
|
socket.on("viewer", () => {
|
||||||
socket.broadcast.emit("viewer", socket.id);
|
if (broadcasterSocketId) {
|
||||||
|
socket.to(broadcasterSocketId).emit("viewer", socket.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebRTC Signaling
|
// WebRTC Signaling
|
||||||
|
|||||||
Reference in New Issue
Block a user