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