+ +
+ + + + +
+

Media Sources

+ +
+ + +
+ + + + +
+ +
Not Broadcasting
+ + +
+ +
+
Res: 0x0
+
FPS: 0
+
Up: 0 kbps
+
V-Codec: ...
+
A-Codec: ...
+ +
+ +
+
- - -
- - - - -
Not Broadcasting
- - -
-
Resolution: 0x0
-
FPS: 0
-
Upstream: 0 kbps
+ +
+
+
+
+
+ +
- - + diff --git a/client/main.js b/client/main.js index 00aff1f..f23b480 100644 --- a/client/main.js +++ b/client/main.js @@ -44,11 +44,35 @@ app.on('window-all-closed', () => { // Handle IPC request from renderer to get screen/audio sources ipcMain.handle('get-sources', async () => { - const inputSources = await desktopCapturer.getSources({ + let inputSources = await desktopCapturer.getSources({ types: ['window', 'screen'], fetchWindowIcons: true }); + // Wayland Workaround: If we only see generic "WebRTC PipeWire capturer" windows, + // try to fetch real window titles via our python helper + try { + const genericNames = ['webrtc pipewire capturer', 'screen 1', 'screen 2']; + const hasGeneric = inputSources.some(s => genericNames.includes(s.name.toLowerCase())); + + if (hasGeneric || inputSources.length === 1) { + const { execSync } = require('child_process'); + const pyPath = path.join(__dirname, 'wayland-helper.py'); + const out = execSync(`python3 ${pyPath}`, { timeout: 2000 }).toString(); + const waylandWindows = JSON.parse(out); + + if (waylandWindows && waylandWindows.length > 0) { + // If we only have 1 capturer source (common on Wayland compositors), + // rename it to the first active window title we found to be helpful. + if (inputSources.length === 1 && waylandWindows[0].title) { + inputSources[0].name = waylandWindows[0].title; + } + } + } + } catch (e) { + console.error("Wayland helper failed:", e.message); + } + return inputSources.map(source => ({ id: source.id, name: source.name, @@ -65,6 +89,10 @@ ipcMain.handle('link-app-audio', async (event, appName) => { return await PipewireHelper.linkApplicationToMic(appName); }); +ipcMain.handle('link-monitor-audio', async () => { + return await PipewireHelper.linkMonitorToMic(); +}); + // Handle saving and loading the config.json profile const fs = require('fs'); const configPath = path.join(__dirname, 'config.json'); diff --git a/client/package-lock.json b/client/package-lock.json index 342d653..bc52d29 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "chart.js": "^4.5.1", "electron": "^40.6.0", "socket.io-client": "^4.8.3" } @@ -34,6 +35,12 @@ "global-agent": "^3.0.0" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -163,6 +170,18 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", diff --git a/client/package.json b/client/package.json index 1948b74..0f00652 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "chart.js": "^4.5.1", "electron": "^40.6.0", "socket.io-client": "^4.8.3" } diff --git a/client/pipewire.js b/client/pipewire.js index 0fa8913..9f4939f 100644 --- a/client/pipewire.js +++ b/client/pipewire.js @@ -12,7 +12,7 @@ class PipewireHelper { try { await execAsync(`pw-cli destroy ${VIRT_MIC_NAME}`).catch(() => {}); // Cleanup old - const cmd = `pw-cli create-node adapter '{ factory.name=support.null-audio-sink node.name=${VIRT_MIC_NAME} node.description="SimpleScreenshare Audio" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] }'`; + const cmd = `pw-cli create-node adapter \'{ factory.name=support.null-audio-sink node.name=${VIRT_MIC_NAME} node.description=\"SimpleScreenshare Audio\" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }\'`; const { stdout } = await execAsync(cmd); console.log("Created virtual mic:", stdout.trim()); @@ -79,6 +79,47 @@ class PipewireHelper { } } + // Remove all existing links TO the virtual mic's input ports + // This prevents echo from stale connections when switching audio sources + static async unlinkAllFromMic() { + try { + // IMPORTANT: Use `pw-link -l` NOT `pw-link -l -I` — the -I flag hangs when piped + const { stdout } = await execAsync(`pw-link -l`, { maxBuffer: 1024 * 1024, timeout: 3000 }).catch(() => ({ stdout: '' })); + if (!stdout) return; + + const lines = stdout.split('\n'); + + // pw-link -l format: + // alsa_output...:monitor_FL (source port - NOT indented) + // |-> simplescreenshare-audio:input_FL (outgoing link - indented with |->) + let currentSourcePort = null; + + for (const line of lines) { + if (!line.trim()) continue; + + // Non-indented line = port declaration + if (!line.startsWith(' ')) { + currentSourcePort = line.trim(); + continue; + } + + // Indented line with |-> targeting our virtual mic + const trimmed = line.trim(); + if (trimmed.startsWith('|->') && (trimmed.includes(`${VIRT_MIC_NAME}:input_`) || trimmed.includes('SimpleScreenshare Audio:input_'))) { + const targetPort = trimmed.replace('|->', '').trim(); + if (currentSourcePort && targetPort) { + console.log(`Unlinking: "${currentSourcePort}" -> "${targetPort}"`); + await execAsync(`pw-link -d "${currentSourcePort}" "${targetPort}"`).catch(e => + console.log("pw-link unlink:", e.message) + ); + } + } + } + } catch (error) { + console.error("Failed to unlink from mic:", error); + } + } + // Link a target application's output to our Virtual Microphone static async linkApplicationToMic(targetAppName) { try { @@ -108,6 +149,9 @@ class PipewireHelper { console.log(`Linking ${targetAppName} (ID: ${targetNode.id}) to ${VIRT_MIC_NAME} (ID: ${micNode.id})`); + // Clean up any existing links to prevent echo from stale connections + await this.unlinkAllFromMic(); + // 4. Find the Ports for both nodes const ports = dump.filter(n => n.type === 'PipeWire:Interface:Port'); @@ -143,6 +187,73 @@ class PipewireHelper { return false; } } + + // Link the system's default audio output monitor to our Virtual Microphone + // This captures ALL desktop audio cleanly via Pipewire without Chromium's broken desktop audio capture + static async linkMonitorToMic() { + try { + const { stdout: dumpOut } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 }); + const dump = JSON.parse(dumpOut); + + // Find the default audio sink (the system's main output) + const sinkNode = dump.find(node => + node.info && + node.info.props && + node.info.props['media.class'] === 'Audio/Sink' && + (node.info.props['node.name'] || '').includes('output') + ); + + // Find our virtual mic node + const micNode = dump.find(node => + node.info && + node.info.props && + node.info.props['node.name'] === VIRT_MIC_NAME + ); + + if (!sinkNode || !micNode) { + console.error("Could not find default sink or virtual mic node"); + return false; + } + + console.log(`Linking system monitor (ID: ${sinkNode.id}) to ${VIRT_MIC_NAME} (ID: ${micNode.id})`); + + // Clean up any existing links to prevent echo from stale connections + await this.unlinkAllFromMic(); + + const ports = dump.filter(n => n.type === 'PipeWire:Interface:Port'); + + // The monitor ports on a sink are "output" direction (they output what the sink is playing) + const sinkMonitorPorts = ports.filter(p => + p.info.props['node.id'] === sinkNode.id && p.info.direction === 'output' + ); + const sinkFL = sinkMonitorPorts.find(p => p.info.props['audio.channel'] === 'FL'); + const sinkFR = sinkMonitorPorts.find(p => p.info.props['audio.channel'] === 'FR'); + + const micPorts = ports.filter(p => p.info.props['node.id'] === micNode.id && p.info.direction === 'input'); + const micFL = micPorts.find(p => p.info.props['audio.channel'] === 'FL'); + const micFR = micPorts.find(p => p.info.props['audio.channel'] === 'FR'); + + if (!sinkFL || !sinkFR || !micFL || !micFR) { + console.error("Could not find stereo monitor/mic ports for linking"); + return false; + } + + const sinkFlAlias = sinkFL.info.props['port.alias'] || sinkFL.info.props['object.path'] || sinkFL.id; + const sinkFrAlias = sinkFR.info.props['port.alias'] || sinkFR.info.props['object.path'] || sinkFR.id; + const micFlAlias = micFL.info.props['port.alias'] || micFL.info.props['object.path'] || micFL.id; + const micFrAlias = micFR.info.props['port.alias'] || micFR.info.props['object.path'] || micFR.id; + + await execAsync(`pw-link "${sinkFlAlias}" "${micFlAlias}"`).catch(e => console.log("pw-link output:", e.message)); + await execAsync(`pw-link "${sinkFrAlias}" "${micFrAlias}"`).catch(e => console.log("pw-link output:", e.message)); + + console.log("Successfully linked system monitor audio."); + return true; + + } catch (error) { + console.error("Failed to link monitor:", error); + return false; + } + } } module.exports = PipewireHelper; diff --git a/client/preload.js b/client/preload.js index 9123d74..0533c7c 100644 --- a/client/preload.js +++ b/client/preload.js @@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSources: () => ipcRenderer.invoke('get-sources'), getAudioApps: () => ipcRenderer.invoke('get-audio-apps'), linkAppAudio: (appName) => ipcRenderer.invoke('link-app-audio', appName), + linkMonitorAudio: () => ipcRenderer.invoke('link-monitor-audio'), getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config) }); diff --git a/client/renderer.js b/client/renderer.js index 236c228..39d8993 100644 --- a/client/renderer.js +++ b/client/renderer.js @@ -8,12 +8,22 @@ const stopBtn = document.getElementById('stopBtn'); const localVideo = document.getElementById('localVideo'); const statusText = document.getElementById('statusText'); const statsPanel = document.getElementById('statsPanel'); +const viewerCountDiv = document.getElementById('viewerCount'); + +function updateViewerCount() { + if (viewerCountDiv) { + viewerCountDiv.innerText = `Viewers: ${Object.keys(peerConnections).length}`; + } +} let socket; let peerConnections = {}; let activeStream; let selectedVideoSourceId = null; +// Chart.js instance tracking +let bitrateChart = null; + const config = { iceServers: [ { urls: "stun:localhost:3478" }, @@ -25,7 +35,6 @@ const config = { // Also enumerate native audio devices from navigator! getSourcesBtn.addEventListener('click', async () => { sourcesGrid.innerHTML = '
Loading sources...
'; - audioSelect.innerHTML = ''; startBtn.disabled = true; selectedVideoSourceId = null; @@ -41,8 +50,9 @@ getSourcesBtn.addEventListener('click', async () => { img.src = source.thumbnail; const label = document.createElement('span'); - label.innerText = source.name; - label.title = source.name; + // source.name usually contains the application name + label.innerText = source.name || `Screen ${source.id}`; + label.title = source.name || `Screen ${source.id}`; item.appendChild(img); item.appendChild(label); @@ -58,23 +68,26 @@ getSourcesBtn.addEventListener('click', async () => { sourcesGrid.appendChild(item); }); - // --- Fetch Application Audio Sources via built Pipewire Helper --- - const audioApps = await window.electronAPI.getAudioApps(); - audioSelect.innerHTML = ''; - audioApps.forEach(app => { - const option = document.createElement('option'); - // We pass the actual application name into the value so the main process can find it via pw-dump - option.value = app.name; - option.text = `${app.name} (${app.mediaName})`; - audioSelect.appendChild(option); - }); + // Add custom formatting if there's only one item (like on Wayland) + if (sources.length === 1) { + sourcesGrid.classList.add('single-item'); + // On Wayland with a single source, just auto-select it WITHOUT calling startPreview. + // startPreview triggers another getUserMedia which opens a SECOND Wayland portal dialog. + // The thumbnail already shows what the source looks like. + selectedVideoSourceId = sources[0].id; + sourcesGrid.firstChild.classList.add('selected'); + startBtn.disabled = false; + } else { + sourcesGrid.classList.remove('single-item'); + } - // If we don't disable start button here, it would be enabled before user clicked a grid item - startBtn.disabled = true; + // Ensure start button remains disabled if no source was auto-selected + if (!selectedVideoSourceId) { + startBtn.disabled = true; + } } catch (e) { console.error(e); sourcesGrid.innerHTML = '
Error loading sources
'; - audioSelect.innerHTML = ''; } }); @@ -90,6 +103,8 @@ async function startPreview(videoSourceId) { if (!videoSourceId) { localVideo.style.display = 'none'; + const placeholder = document.getElementById('videoPlaceholder'); + if (placeholder) placeholder.style.display = 'block'; return; } @@ -110,11 +125,74 @@ async function startPreview(videoSourceId) { localVideo.srcObject = previewStream; localVideo.style.display = 'block'; + const placeholder = document.getElementById('videoPlaceholder'); + if (placeholder) placeholder.style.display = 'none'; } catch (e) { console.error("Failed to start preview stream:", e); } } +// --- Audio Capture Helper --- +async function getAudioStream(targetAppName, videoSourceId) { + if (!targetAppName || targetAppName === 'none') return null; + + if (targetAppName === 'all_desktop') { + // Use Pipewire to link the system's default audio output monitor to our virtual mic. + // This avoids Chromium's broken chromeMediaSource desktop audio which causes echoing + // and double Wayland ScreenCast portal prompts. + const linked = await window.electronAPI.linkMonitorAudio(); + if (linked) { + const devices = await navigator.mediaDevices.enumerateDevices(); + const virtMic = devices.find(d => d.kind === 'audioinput' && d.label.toLowerCase().includes('simplescreenshare')); + + if (virtMic) { + return await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: { exact: virtMic.deviceId }, + echoCancellation: { exact: false }, + autoGainControl: { exact: false }, + noiseSuppression: { exact: false }, + channelCount: 2, + sampleRate: 48000 + }, + video: false + }); + } else { + console.warn("Virtual mic device not found for monitor capture"); + } + } else { + console.warn("Failed to link system monitor audio."); + } + return null; + } + + // Application specific (Pipewire) + const linked = await window.electronAPI.linkAppAudio(targetAppName); + if (linked) { + const devices = await navigator.mediaDevices.enumerateDevices(); + const virtMic = devices.find(d => d.kind === 'audioinput' && d.label.toLowerCase().includes('simplescreenshare')); + + if (virtMic) { + return await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: { exact: virtMic.deviceId }, + echoCancellation: { exact: false }, + autoGainControl: { exact: false }, + noiseSuppression: { exact: false }, + channelCount: 2, + sampleRate: 48000 + }, + video: false + }); + } else { + console.warn("Virtual mic device not found in navigator enumeration"); + } + } else { + console.warn("Failed to link application audio."); + } + return null; +} + // 2. Start Broadcast startBtn.addEventListener('click', async () => { const url = serverUrlInput.value; @@ -122,8 +200,8 @@ startBtn.addEventListener('click', async () => { const videoSourceId = selectedVideoSourceId; const targetAppName = audioSelect.value; - if (!videoSourceId || !url || !password) { - alert("Please fill out URL, Password, and select a visual source."); + if (!url || !password) { + alert("Please fill out URL and Password."); return; } @@ -131,56 +209,33 @@ startBtn.addEventListener('click', async () => { window.electronAPI.saveConfig({ serverUrl: url, serverPassword: password }); try { - // Stop the preview grab so we can grab the real stream cleanly + // Reuse the preview stream if available, otherwise create a new one. + // On Wayland, this is typically the ONLY portal prompt since we skip getSources on startup. + let stream; if (previewStream) { - previewStream.getTracks().forEach(t => t.stop()); + stream = previewStream; previewStream = null; + } else { + // Build video constraints — omit chromeMediaSourceId if no source was pre-selected. + // On Wayland this lets the portal handle source selection. + const videoMandatory = { chromeMediaSource: 'desktop' }; + if (selectedVideoSourceId) { + videoMandatory.chromeMediaSourceId = selectedVideoSourceId; + } + stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { mandatory: videoMandatory } + }); } - const stream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: videoSourceId, - } - } - }); - const videoTrack = stream.getVideoTracks()[0]; - await videoTrack.applyConstraints({ frameRate: { ideal: 60 } }); + if (videoTrack) await videoTrack.applyConstraints({ frameRate: { ideal: 60 } }); - // If user selected an application, grab the Virtual Mic input and link the app to it! + // Add audio if requested (virtual mic capture does NOT trigger a Wayland portal) if (targetAppName && targetAppName !== 'none') { - const linked = await window.electronAPI.linkAppAudio(targetAppName); - if (linked) { - // Now that the pipewire graph is linked, we just need to read from our Virtual Mic sink! - // Chromium registers this as a standard Input device - const devices = await navigator.mediaDevices.enumerateDevices(); - const virtMic = devices.find(d => d.kind === 'audioinput' && d.label.toLowerCase().includes('simplescreenshare')); - - if (virtMic) { - const audioStream = await navigator.mediaDevices.getUserMedia({ - audio: { - deviceId: { exact: virtMic.deviceId }, - echoCancellation: false, - autoGainControl: false, - noiseSuppression: false, - googAutoGainControl: false, - googEchoCancellation: false, - googNoiseSuppression: false, - googHighpassFilter: false, - channelCount: 2, - sampleRate: 48000 - }, - video: false - }); - stream.addTrack(audioStream.getAudioTracks()[0]); - } else { - console.warn("Virtual mic device not found in navigator enumeration"); - } - } else { - alert("Failed to link application audio. Broadcasting video only."); + const audioStream = await getAudioStream(targetAppName, videoSourceId); + if (audioStream) { + stream.addTrack(audioStream.getAudioTracks()[0]); } } @@ -193,6 +248,7 @@ startBtn.addEventListener('click', async () => { startBtn.style.display = 'none'; stopBtn.style.display = 'inline-block'; statsPanel.style.display = 'block'; + if (viewerCountDiv) viewerCountDiv.style.display = 'block'; statusText.innerText = `Broadcasting to ${url}`; // Auto stop if user closes the requested window @@ -221,6 +277,7 @@ function connectAndBroadcast(url, password) { const peerConnection = new RTCPeerConnection(config); peerConnections[id] = peerConnection; + updateViewerCount(); activeStream.getTracks().forEach(track => { const sender = peerConnection.addTrack(track, activeStream); @@ -297,6 +354,7 @@ function connectAndBroadcast(url, password) { if (peerConnections[id]) { peerConnections[id].close(); delete peerConnections[id]; + updateViewerCount(); } }); } @@ -314,14 +372,71 @@ function stopSharing() { peerConnections = {}; localVideo.style.display = 'none'; + const placeholder = document.getElementById('videoPlaceholder'); + if (placeholder) placeholder.style.display = 'block'; + statsPanel.style.display = 'none'; startBtn.style.display = 'inline-block'; stopBtn.style.display = 'none'; statusText.innerText = 'Not Broadcasting'; + if (viewerCountDiv) { + viewerCountDiv.style.display = 'none'; + viewerCountDiv.innerText = 'Viewers: 0'; + } + + if (bitrateChart) { + bitrateChart.destroy(); + bitrateChart = null; + } } stopBtn.addEventListener('click', stopSharing); +// --- Dynamic Audio Switching --- +audioSelect.addEventListener('change', async () => { + if (!activeStream) return; // ignore if not actively broadcasting + + const targetAppName = audioSelect.value; + try { + const newAudioStream = await getAudioStream(targetAppName, selectedVideoSourceId); + const newAudioTrack = newAudioStream ? newAudioStream.getAudioTracks()[0] : null; + + // Remove old track from local active stream + const oldAudioTracks = activeStream.getAudioTracks(); + if (oldAudioTracks.length > 0) { + oldAudioTracks.forEach(t => { + t.stop(); + activeStream.removeTrack(t); + }); + } + + // Add new track + if (newAudioTrack) { + activeStream.addTrack(newAudioTrack); + } + + // Directly hot-swap the audio track on all established WebRTC connections + Object.values(peerConnections).forEach(pc => { + const sender = pc.getSenders().find(s => s.track && s.track.kind === 'audio'); + + // `replaceTrack` allows hot-swapping without renegotiation! + // If newAudioTrack is null (No Audio), replacing with null mutes the stream nicely. + if (sender) { + sender.replaceTrack(newAudioTrack || null).catch(e => console.error("replaceTrack error:", e)); + } else if (newAudioTrack) { + // Edge case: if the broadcast was originally started with 'No Audio', + // there's no audio transceiver created yet! + // We'd have to trigger renegotiation to add one, which acts as a restart. + console.warn("Cannot add audio dynamically to a stream that started with 'No Audio'. Please restart the broadcast."); + alert("Cannot swap to audio mid-stream if the broadcast started with 'No Audio'. Please stop and restart."); + } + }); + + } catch (e) { + console.error("Failed to switch audio dynamically:", e); + } +}); + // --- Stats Monitoring Loop --- let lastBytesSent = 0; let lastTimestamp = 0; @@ -329,12 +444,61 @@ let lastTimestamp = 0; setInterval(async () => { if (!activeStream || Object.keys(peerConnections).length === 0) return; + // Initialize chart if not present + if (!bitrateChart) { + const ctx = document.getElementById('bitrateChart').getContext('2d'); + bitrateChart = new Chart(ctx, { + type: 'line', + data: { + labels: Array(20).fill(''), + datasets: [{ + label: 'Bitrate (kbps)', + data: Array(20).fill(0), + borderColor: '#aaaaaa', + backgroundColor: 'rgba(170, 170, 170, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + pointRadius: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + legend: { display: false } + }, + scales: { + x: { display: false }, + y: { + display: true, + position: 'right', + ticks: { color: '#94a3b8', font: { size: 10 } }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); + } + // Get stats from the first active peer connection const pc = Object.values(peerConnections)[0]; if (!pc) return; try { const stats = await pc.getStats(); + let videoCodec = 'Unknown'; + let audioCodec = 'Unknown'; + + // Scan for codec objects globally + stats.forEach(report => { + if (report.type === 'codec') { + if (report.mimeType.toLowerCase().includes('video')) videoCodec = report.mimeType.split('/')[1] || report.mimeType; + if (report.mimeType.toLowerCase().includes('audio')) audioCodec = report.mimeType.split('/')[1] || report.mimeType; + } + }); + stats.forEach(report => { if (report.type === 'outbound-rtp' && report.kind === 'video') { const fps = report.framesPerSecond || 0; @@ -355,14 +519,52 @@ setInterval(async () => { document.getElementById('statsFps').innerText = fps; document.getElementById('statsRes').innerText = res; document.getElementById('statsBitrate').innerText = bitrate + ' kbps'; + document.getElementById('statsVideoCodec').innerText = videoCodec; + + // Update chart + if (bitrateChart) { + bitrateChart.data.datasets[0].data.shift(); + bitrateChart.data.datasets[0].data.push(bitrate); + bitrateChart.update(); + } + + } else if (report.type === 'outbound-rtp' && report.kind === 'audio') { + document.getElementById('statsAudioCodec').innerText = audioCodec; } }); } catch (e) { console.error("Stats error", e); } }, 1000); -// Initial load of sources & config +// Initial load: config + audio apps only (no portal prompt on startup) window.electronAPI.getConfig().then(cfg => { if (cfg.serverUrl) serverUrlInput.value = cfg.serverUrl; if (cfg.serverPassword) serverPasswordInput.value = cfg.serverPassword; }); -getSourcesBtn.click(); + +// Fetch audio applications on startup (this only reads PipeWire, no Wayland portal) +(async () => { + try { + const audioApps = await window.electronAPI.getAudioApps(); + audioSelect.innerHTML = ''; + + const allDesktopOption = document.createElement('option'); + allDesktopOption.value = 'all_desktop'; + allDesktopOption.text = 'All Desktop Audio (System Default)'; + audioSelect.appendChild(allDesktopOption); + + audioApps.forEach(app => { + const option = document.createElement('option'); + option.value = app.name; + option.text = `${app.name} (${app.mediaName})`; + audioSelect.appendChild(option); + }); + } catch (e) { + console.error('Failed to load audio apps:', e); + audioSelect.innerHTML = ''; + } + + // Show the source grid as ready (user can optionally click "Select Sources" for thumbnails) + sourcesGrid.innerHTML = '
Click "Start Broadcast" to select a source, or use "Select Sources" for thumbnails.
'; + // Start button is always enabled — source selection happens via the portal + startBtn.disabled = false; +})(); diff --git a/client/wayland-helper.py b/client/wayland-helper.py new file mode 100755 index 0000000..8e9c864 --- /dev/null +++ b/client/wayland-helper.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import json +import subprocess +import os + +def get_wayland_windows(): + """ + Since Wayland aggressively isolates window metadata from standard utilities, + and KWin DBus scripts are restricted on this machine, we check for common + running GUI applications via `ps` to label the composite pipewire sink. + """ + windows = [] + + # Try XWayland fallback first + try: + wmctrl_out = subprocess.run(['wmctrl', '-l'], capture_output=True, text=True, timeout=1).stdout + for line in wmctrl_out.splitlines(): + parts = line.split(maxsplit=3) + if len(parts) >= 4: + windows.append({"title": parts[3]}) + except Exception: + pass + + # Process scraping for common GUI apps on Wayland + try: + ps_out = subprocess.run(['ps', '-eo', 'comm='], capture_output=True, text=True).stdout + running_procs = ps_out.lower().splitlines() + + common_apps = { + 'spotify': 'Spotify', + 'discord': 'Discord', + 'chrome': 'Google Chrome', + 'chromium': 'Chromium', + 'firefox': 'Firefox', + 'code': 'VS Code', + 'obsidian': 'Obsidian', + 'telegram': 'Telegram', + 'slack': 'Slack', + 'steam': 'Steam' + } + + for proc, name in common_apps.items(): + if proc in running_procs and not any(name in w["title"] for w in windows): + windows.append({"title": name}) + + except Exception: + pass + + # If we found absolutely nothing, provide a generic fallback + if not windows: + windows.append({"title": "Wayland Desktop / App"}) + + print(json.dumps(windows)) + +if __name__ == '__main__': + get_wayland_windows()