refactor: Migrate screen sharing media handling from direct WebRTC to Mediasoup.
This commit is contained in:
@@ -308,6 +308,7 @@
|
||||
<!-- Use socket.io client script locally installed via npm -->
|
||||
<script src="./node_modules/socket.io-client/dist/socket.io.js"></script>
|
||||
<script src="./node_modules/chart.js/dist/chart.umd.js"></script>
|
||||
<script src="./mediasoup-client.bundle.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,8 +5,10 @@ const PipewireHelper = require('./pipewire');
|
||||
// Ensure Chromium tries to use PipeWire for screen sharing on Linux
|
||||
app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer');
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 800,
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
@@ -17,7 +19,11 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
win.loadFile('index.html');
|
||||
mainWindow.loadFile('index.html');
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
@@ -26,6 +32,18 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow();
|
||||
|
||||
// Monitor PipeWire graph for new/removed audio streams
|
||||
PipewireHelper.startMonitoring(async () => {
|
||||
try {
|
||||
const apps = await PipewireHelper.getAudioApplications();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('audio-apps-updated', apps);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh audio apps:', e);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
@@ -34,6 +52,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
|
||||
app.on('will-quit', async () => {
|
||||
PipewireHelper.stopMonitoring();
|
||||
await PipewireHelper.destroyVirtualMic();
|
||||
});
|
||||
|
||||
|
||||
4
client/mediasoup-entry.js
Normal file
4
client/mediasoup-entry.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Entry point for esbuild browser bundle
|
||||
// Bundles mediasoup-client as a global `mediasoupClient` for use in renderer.js
|
||||
const mediasoupClient = require('mediasoup-client');
|
||||
module.exports = mediasoupClient;
|
||||
330
client/package-lock.json
generated
330
client/package-lock.json
generated
@@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"electron": "^40.6.0",
|
||||
"mediasoup": "^3.19.17",
|
||||
"mediasoup-client": "^3.18.7",
|
||||
"socket.io-client": "^4.8.3"
|
||||
}
|
||||
},
|
||||
@@ -35,12 +37,45 @@
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.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/@lukeed/csprng": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||
"integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/uuid": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz",
|
||||
"integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/csprng": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
@@ -83,6 +118,22 @@
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/events-alias": {
|
||||
"name": "@types/events",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -98,6 +149,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
|
||||
@@ -126,6 +183,22 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/awaitqueue": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-3.3.0.tgz",
|
||||
"integrity": "sha512-zLxDhzQbzHmOyvxi7g3OlfR7jLrcmElStPxfLPpJkrFSDm71RSrY/MvsDA8Btlx8X1nOHUzGhQvc6bdUjL2f2w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@@ -182,6 +255,15 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
@@ -194,6 +276,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -388,6 +479,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/events-alias": {
|
||||
"name": "events",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -408,6 +509,18 @@
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fake-mediastreamtrack": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-2.2.1.tgz",
|
||||
"integrity": "sha512-SITLc7UTDirSdgLGORrlmqjWLJtbtfIz/xO7DwVbJH3f0ds+NQok4ccl/WEzz49NSgiUlXf2wDW0+y5C+TO6EA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@lukeed/uuid": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -417,6 +530,47 @@
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/flatbuffers": {
|
||||
"version": "25.9.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
@@ -538,6 +692,22 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h264-profile-level-id": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.2.tgz",
|
||||
"integrity": "sha512-hnq1UDlw7WGJV6GCr/g7wnkHYUjdAY2bis9rgn2JqSdQS2WfVvnt1ZE9g8nTguracodf5LLKZOwURsDN49YtBQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
@@ -623,6 +793,52 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mediasoup": {
|
||||
"version": "3.19.17",
|
||||
"resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.19.17.tgz",
|
||||
"integrity": "sha512-wnmp/0dd56GBR5LzP+DXnDSAggykl9RncPIoUsZJffW/ggByyTqUjhC78lPGOPta+xmYLR0bKsogGQLi26S+9g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"flatbuffers": "^25.9.23",
|
||||
"h264-profile-level-id": "^2.3.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"supports-color": "^10.2.2",
|
||||
"tar": "^7.5.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/mediasoup-client": {
|
||||
"version": "3.18.7",
|
||||
"resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.18.7.tgz",
|
||||
"integrity": "sha512-110f+zYEvSllYoF6didbIznIvSrTwMY9CqDhdWpq1M0goX1HWLoBdguiYB/MqcA858R2dOKDarP1R4vv5RAg1w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/events-alias": "npm:@types/events@^3.0.3",
|
||||
"awaitqueue": "^3.3.0",
|
||||
"debug": "^4.4.3",
|
||||
"events-alias": "npm:events@^3.3.0",
|
||||
"fake-mediastreamtrack": "^2.2.1",
|
||||
"h264-profile-level-id": "^2.3.2",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"supports-color": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mediasoup"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||
@@ -632,12 +848,71 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
@@ -751,6 +1026,15 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -830,6 +1114,34 @@
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.9",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
@@ -858,6 +1170,15 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -893,6 +1214,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"electron": "^40.6.0",
|
||||
"mediasoup": "^3.19.17",
|
||||
"mediasoup-client": "^3.18.7",
|
||||
"socket.io-client": "^4.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,55 @@ class PipewireHelper {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Monitor PipeWire graph for changes (new/removed audio streams)
|
||||
// Calls `onChange` whenever a relevant change is detected (debounced)
|
||||
static _monitorProcess = null;
|
||||
static _debounceTimer = null;
|
||||
|
||||
static startMonitoring(onChange) {
|
||||
if (this._monitorProcess) return; // already monitoring
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
// pw-mon outputs a line for every PipeWire graph event (node added, removed, etc.)
|
||||
const proc = spawn('pw-mon', ['--color=never'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
|
||||
this._monitorProcess = proc;
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
// Only react to node add/remove events that are likely audio-related
|
||||
if (text.includes('added') || text.includes('removed')) {
|
||||
// Debounce: multiple events fire in quick succession when an app starts/stops
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
onChange();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('pw-mon failed to start:', err.message);
|
||||
this._monitorProcess = null;
|
||||
});
|
||||
|
||||
proc.on('exit', () => {
|
||||
this._monitorProcess = null;
|
||||
});
|
||||
|
||||
console.log('Started PipeWire graph monitoring (pw-mon)');
|
||||
}
|
||||
|
||||
static stopMonitoring() {
|
||||
if (this._monitorProcess) {
|
||||
this._monitorProcess.kill();
|
||||
this._monitorProcess = null;
|
||||
}
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = null;
|
||||
console.log('Stopped PipeWire graph monitoring');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PipewireHelper;
|
||||
|
||||
@@ -6,5 +6,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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)
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
onAudioAppsUpdated: (callback) => ipcRenderer.on('audio-apps-updated', (_event, apps) => callback(apps))
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// mediasoupClient is loaded via <script> tag in index.html (esbuild bundle)
|
||||
const { Device } = mediasoupClient;
|
||||
|
||||
const serverUrlInput = document.getElementById('serverUrl');
|
||||
const serverPasswordInput = document.getElementById('serverPassword');
|
||||
const sourcesGrid = document.getElementById('sourcesGrid');
|
||||
@@ -11,39 +14,19 @@ const statsPanel = document.getElementById('statsPanel');
|
||||
const viewerCountDiv = document.getElementById('viewerCount');
|
||||
const qualitySelect = document.getElementById('qualitySelect');
|
||||
|
||||
function updateViewerCount() {
|
||||
if (viewerCountDiv) {
|
||||
viewerCountDiv.innerText = `Viewers: ${Object.keys(peerConnections).length}`;
|
||||
}
|
||||
}
|
||||
|
||||
let socket;
|
||||
let peerConnections = {};
|
||||
let activeStream;
|
||||
let selectedVideoSourceId = null;
|
||||
|
||||
// --- Mediasoup State ---
|
||||
let device;
|
||||
let sendTransport;
|
||||
let videoProducer;
|
||||
let audioProducer;
|
||||
|
||||
// Chart.js instance tracking
|
||||
let bitrateChart = null;
|
||||
|
||||
// Build ICE config dynamically based on server URL
|
||||
function getIceConfig(serverUrl, turnUser = 'myuser', turnPass = 'mypassword') {
|
||||
let turnHost = 'localhost';
|
||||
try {
|
||||
const url = new URL(serverUrl);
|
||||
turnHost = url.hostname;
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
iceServers: [
|
||||
{ urls: `stun:${turnHost}:3478` },
|
||||
{ urls: `turn:${turnHost}:3478`, username: turnUser, credential: turnPass }
|
||||
],
|
||||
iceCandidatePoolSize: 5
|
||||
};
|
||||
}
|
||||
|
||||
let config = getIceConfig('http://localhost:3000');
|
||||
|
||||
// 1. Get Desktop Sources / Switch Video Source Mid-Stream
|
||||
getSourcesBtn.addEventListener('click', async () => {
|
||||
// --- Mid-Stream Video Source Switching ---
|
||||
@@ -51,7 +34,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
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();
|
||||
@@ -60,7 +42,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
// 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' } }
|
||||
@@ -77,22 +58,16 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
}
|
||||
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));
|
||||
}
|
||||
});
|
||||
// Hot-swap on the mediasoup producer (no renegotiation needed!)
|
||||
if (videoProducer) {
|
||||
await videoProducer.replaceTrack({ track: newVideoTrack });
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -104,7 +79,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
selectedVideoSourceId = null;
|
||||
|
||||
try {
|
||||
// --- Fetch Virtual Video Sources ---
|
||||
const sources = await window.electronAPI.getSources();
|
||||
sourcesGrid.innerHTML = '';
|
||||
sources.forEach(source => {
|
||||
@@ -115,7 +89,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
img.src = source.thumbnail;
|
||||
|
||||
const label = document.createElement('span');
|
||||
// source.name usually contains the application name
|
||||
label.innerText = source.name || `Screen ${source.id}`;
|
||||
label.title = source.name || `Screen ${source.id}`;
|
||||
|
||||
@@ -133,12 +106,8 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
sourcesGrid.appendChild(item);
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -146,7 +115,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
sourcesGrid.classList.remove('single-item');
|
||||
}
|
||||
|
||||
// Ensure start button remains disabled if no source was auto-selected
|
||||
if (!selectedVideoSourceId) {
|
||||
startBtn.disabled = true;
|
||||
}
|
||||
@@ -160,7 +128,6 @@ getSourcesBtn.addEventListener('click', async () => {
|
||||
let previewStream = null;
|
||||
|
||||
async function startPreview(videoSourceId) {
|
||||
// Cleanup previous preview
|
||||
if (previewStream) {
|
||||
previewStream.getTracks().forEach(t => t.stop());
|
||||
previewStream = null;
|
||||
@@ -184,7 +151,6 @@ async function startPreview(videoSourceId) {
|
||||
}
|
||||
});
|
||||
|
||||
// Removed 1080p ideal limit to prevent Chromium from green-padding non-16:9 window captures!
|
||||
const videoTrack = previewStream.getVideoTracks()[0];
|
||||
await videoTrack.applyConstraints({ frameRate: { ideal: 60 } });
|
||||
|
||||
@@ -202,9 +168,6 @@ 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();
|
||||
@@ -270,19 +233,14 @@ startBtn.addEventListener('click', async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials for next time
|
||||
window.electronAPI.saveConfig({ serverUrl: url, serverPassword: password });
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
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;
|
||||
@@ -297,7 +255,6 @@ startBtn.addEventListener('click', async () => {
|
||||
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') {
|
||||
const audioStream = await getAudioStream(targetAppName, videoSourceId);
|
||||
if (audioStream) {
|
||||
@@ -309,7 +266,7 @@ startBtn.addEventListener('click', async () => {
|
||||
localVideo.srcObject = stream;
|
||||
localVideo.style.display = 'block';
|
||||
|
||||
connectAndBroadcast(url, password);
|
||||
await connectAndBroadcast(url, password);
|
||||
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
@@ -317,7 +274,6 @@ startBtn.addEventListener('click', async () => {
|
||||
if (viewerCountDiv) viewerCountDiv.style.display = 'block';
|
||||
statusText.innerText = `Broadcasting to ${url}`;
|
||||
|
||||
// Auto stop if user closes the requested window
|
||||
stream.getVideoTracks()[0].onended = stopSharing;
|
||||
|
||||
} catch (e) {
|
||||
@@ -326,131 +282,161 @@ startBtn.addEventListener('click', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function connectAndBroadcast(url, password) {
|
||||
// Fetch TURN credentials from the server, then update ICE config
|
||||
fetch(new URL('/turn-config', url).href)
|
||||
.then(r => r.json())
|
||||
.then(turn => {
|
||||
config = getIceConfig(url, turn.username, turn.credential);
|
||||
})
|
||||
.catch(() => {
|
||||
config = getIceConfig(url); // fallback to defaults
|
||||
});
|
||||
|
||||
socket = io(url);
|
||||
// --- Mediasoup SFU Connection ---
|
||||
async function connectAndBroadcast(url, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// io() is available globally from socket.io-client script tag in index.html
|
||||
socket = io(url);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('broadcaster', password);
|
||||
});
|
||||
socket.on('connect', async () => {
|
||||
try {
|
||||
// 1. Authenticate as broadcaster
|
||||
socket.emit('broadcaster', password);
|
||||
|
||||
socket.on('authError', (msg) => {
|
||||
alert(msg);
|
||||
stopSharing();
|
||||
});
|
||||
// 2. Get router RTP capabilities
|
||||
const rtpCapabilities = await new Promise((res, rej) => {
|
||||
socket.emit('getRouterRtpCapabilities', (data) => {
|
||||
if (data.error) rej(new Error(data.error));
|
||||
else res(data);
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('viewer', id => {
|
||||
if (!activeStream) return;
|
||||
|
||||
const peerConnection = new RTCPeerConnection(config);
|
||||
peerConnections[id] = peerConnection;
|
||||
updateViewerCount();
|
||||
// 3. Create mediasoup Device and load capabilities
|
||||
device = new Device();
|
||||
await device.load({ routerRtpCapabilities: rtpCapabilities });
|
||||
|
||||
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 = parseInt(targetBitrate);
|
||||
sender.setParameters(params).catch(e => console.error(e));
|
||||
} else if (track.kind === 'audio') {
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings) params.encodings = [{}];
|
||||
params.encodings[0].maxBitrate = 510000; // max Opus bitrate
|
||||
sender.setParameters(params).catch(e => console.error(e));
|
||||
}
|
||||
});
|
||||
// 4. Create send transport
|
||||
const transportParams = await new Promise((res, rej) => {
|
||||
socket.emit('createWebRtcTransport', { direction: 'send' }, (data) => {
|
||||
if (data.error) rej(new Error(data.error));
|
||||
else res(data);
|
||||
});
|
||||
});
|
||||
|
||||
peerConnection.onicecandidate = event => {
|
||||
if (event.candidate) {
|
||||
socket.emit('candidate', id, event.candidate);
|
||||
}
|
||||
};
|
||||
sendTransport = device.createSendTransport(transportParams);
|
||||
|
||||
// Monitor ICE state for stability
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log(`Viewer ${id} ICE state:`, peerConnection.iceConnectionState);
|
||||
if (peerConnection.iceConnectionState === 'failed') {
|
||||
peerConnection.restartIce();
|
||||
} else if (peerConnection.iceConnectionState === 'disconnected') {
|
||||
setTimeout(() => {
|
||||
if (peerConnections[id] && peerConnections[id].iceConnectionState === 'disconnected') {
|
||||
peerConnections[id].restartIce();
|
||||
// Transport 'connect' event: DTLS handshake
|
||||
sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => {
|
||||
try {
|
||||
await new Promise((res, rej) => {
|
||||
socket.emit('connectTransport', {
|
||||
transportId: sendTransport.id,
|
||||
dtlsParameters
|
||||
}, (result) => {
|
||||
if (result && result.error) rej(new Error(result.error));
|
||||
else res();
|
||||
});
|
||||
});
|
||||
callback();
|
||||
} catch (e) {
|
||||
errback(e);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
peerConnection.createOffer().then(sdp => {
|
||||
if (window.RTCRtpSender && window.RTCRtpSender.getCapabilities) {
|
||||
const caps = window.RTCRtpSender.getCapabilities('video');
|
||||
if (caps && caps.codecs) {
|
||||
const h264 = caps.codecs.filter(c => c.mimeType.toLowerCase() === 'video/h264' || c.mimeType.toLowerCase() === 'video/vp8');
|
||||
const transceivers = peerConnection.getTransceivers();
|
||||
transceivers.forEach(t => {
|
||||
if (t.receiver.track.kind === 'video') t.setCodecPreferences(h264);
|
||||
// Transport 'produce' event: server creates the Producer
|
||||
sendTransport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
|
||||
try {
|
||||
const result = await new Promise((res, rej) => {
|
||||
socket.emit('produce', {
|
||||
transportId: sendTransport.id,
|
||||
kind,
|
||||
rtpParameters
|
||||
}, (data) => {
|
||||
if (data.error) rej(new Error(data.error));
|
||||
else res(data);
|
||||
});
|
||||
});
|
||||
callback({ id: result.id });
|
||||
} catch (e) {
|
||||
errback(e);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Produce video
|
||||
const videoTrack = activeStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
const [targetBitrate] = (qualitySelect.value || '8000000|60').split('|');
|
||||
videoProducer = await sendTransport.produce({
|
||||
track: videoTrack,
|
||||
encodings: [{
|
||||
maxBitrate: parseInt(targetBitrate),
|
||||
}],
|
||||
codecOptions: {
|
||||
videoGoogleStartBitrate: 1000
|
||||
}
|
||||
});
|
||||
|
||||
videoProducer.on('transportclose', () => {
|
||||
videoProducer = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebRTC defaults to voice-optimized ~32kbps mono. Let's force high-fidelity stereo!
|
||||
let sdpLines = sdp.sdp.split('\r\n');
|
||||
let opusPayloadType = null;
|
||||
for (let i = 0; i < sdpLines.length; i++) {
|
||||
if (sdpLines[i].includes('a=rtpmap:') && sdpLines[i].includes('opus/48000/2')) {
|
||||
const match = sdpLines[i].match(/a=rtpmap:(\d+) /);
|
||||
if (match) opusPayloadType = match[1];
|
||||
}
|
||||
}
|
||||
if (opusPayloadType) {
|
||||
let fmtpFound = false;
|
||||
for (let i = 0; i < sdpLines.length; i++) {
|
||||
if (sdpLines[i].startsWith(`a=fmtp:${opusPayloadType}`)) {
|
||||
// Completely overwrite the opus config for pristine stereo
|
||||
sdpLines[i] = `a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`;
|
||||
fmtpFound = true;
|
||||
}
|
||||
}
|
||||
if (!fmtpFound) {
|
||||
sdpLines.push(`a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`);
|
||||
}
|
||||
}
|
||||
sdp.sdp = sdpLines.join('\r\n');
|
||||
|
||||
return peerConnection.setLocalDescription(sdp);
|
||||
}).then(() => {
|
||||
socket.emit('offer', id, peerConnection.localDescription);
|
||||
// 6. Produce audio (if present)
|
||||
const audioTrack = activeStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
audioProducer = await sendTransport.produce({
|
||||
track: audioTrack,
|
||||
codecOptions: {
|
||||
opusStereo: true,
|
||||
opusDtx: true,
|
||||
opusMaxPlaybackRate: 48000,
|
||||
opusMaxAverageBitrate: 510000
|
||||
}
|
||||
});
|
||||
|
||||
audioProducer.on('transportclose', () => {
|
||||
audioProducer = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Track viewer count
|
||||
socket.on('viewerCount', (count) => {
|
||||
if (viewerCountDiv) viewerCountDiv.innerText = `Viewers: ${count}`;
|
||||
});
|
||||
|
||||
// Get initial viewer count
|
||||
socket.emit('getViewerCount', (count) => {
|
||||
if (viewerCountDiv) viewerCountDiv.innerText = `Viewers: ${count}`;
|
||||
});
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.error('Mediasoup setup error:', e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('answer', (id, description) => {
|
||||
if (peerConnections[id]) peerConnections[id].setRemoteDescription(description);
|
||||
});
|
||||
socket.on('authError', (msg) => {
|
||||
alert(msg);
|
||||
stopSharing();
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
socket.on('candidate', (id, candidate) => {
|
||||
if (peerConnections[id]) peerConnections[id].addIceCandidate(new RTCIceCandidate(candidate));
|
||||
});
|
||||
|
||||
socket.on('disconnectPeer', id => {
|
||||
if (peerConnections[id]) {
|
||||
peerConnections[id].close();
|
||||
delete peerConnections[id];
|
||||
updateViewerCount();
|
||||
}
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('Socket connection error:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopSharing() {
|
||||
// Close producers
|
||||
if (videoProducer) {
|
||||
videoProducer.close();
|
||||
videoProducer = null;
|
||||
}
|
||||
if (audioProducer) {
|
||||
audioProducer.close();
|
||||
audioProducer = null;
|
||||
}
|
||||
|
||||
// Close transport
|
||||
if (sendTransport) {
|
||||
sendTransport.close();
|
||||
sendTransport = null;
|
||||
}
|
||||
|
||||
device = null;
|
||||
|
||||
if (activeStream) {
|
||||
activeStream.getTracks().forEach(t => t.stop());
|
||||
activeStream = null;
|
||||
@@ -459,8 +445,6 @@ function stopSharing() {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
Object.values(peerConnections).forEach(pc => pc.close());
|
||||
peerConnections = {};
|
||||
|
||||
localVideo.style.display = 'none';
|
||||
const placeholder = document.getElementById('videoPlaceholder');
|
||||
@@ -485,7 +469,7 @@ stopBtn.addEventListener('click', stopSharing);
|
||||
|
||||
// --- Dynamic Audio Switching ---
|
||||
audioSelect.addEventListener('change', async () => {
|
||||
if (!activeStream) return; // ignore if not actively broadcasting
|
||||
if (!activeStream || !sendTransport) return;
|
||||
|
||||
const targetAppName = audioSelect.value;
|
||||
try {
|
||||
@@ -501,27 +485,31 @@ audioSelect.addEventListener('change', async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.");
|
||||
}
|
||||
});
|
||||
// Hot-swap on the mediasoup audio producer
|
||||
if (audioProducer && newAudioTrack) {
|
||||
await audioProducer.replaceTrack({ track: newAudioTrack });
|
||||
} else if (audioProducer && !newAudioTrack) {
|
||||
// Mute by pausing the producer
|
||||
await audioProducer.pause();
|
||||
} else if (!audioProducer && newAudioTrack) {
|
||||
// Need to create a new producer for audio
|
||||
audioProducer = await sendTransport.produce({
|
||||
track: newAudioTrack,
|
||||
codecOptions: {
|
||||
opusStereo: true,
|
||||
opusDtx: true,
|
||||
opusMaxPlaybackRate: 48000,
|
||||
opusMaxAverageBitrate: 510000
|
||||
}
|
||||
});
|
||||
audioProducer.on('transportclose', () => {
|
||||
audioProducer = null;
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Failed to switch audio dynamically:", e);
|
||||
@@ -530,7 +518,7 @@ audioSelect.addEventListener('change', async () => {
|
||||
|
||||
// --- Dynamic Quality Switching ---
|
||||
qualitySelect.addEventListener('change', async () => {
|
||||
if (!activeStream) return;
|
||||
if (!activeStream || !videoProducer) return;
|
||||
|
||||
const [targetBitrate, targetFps] = qualitySelect.value.split('|');
|
||||
|
||||
@@ -540,17 +528,18 @@ qualitySelect.addEventListener('change', async () => {
|
||||
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 = [{}];
|
||||
// Update max bitrate on the producer's encoding
|
||||
if (videoProducer) {
|
||||
try {
|
||||
const params = videoProducer.rtpSender.getParameters();
|
||||
if (params.encodings && params.encodings.length > 0) {
|
||||
params.encodings[0].maxBitrate = parseInt(targetBitrate);
|
||||
sender.setParameters(params).catch(e => console.error(e));
|
||||
await videoProducer.rtpSender.setParameters(params);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update bitrate:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Stats Monitoring Loop ---
|
||||
@@ -558,7 +547,7 @@ let lastBytesSent = 0;
|
||||
let lastTimestamp = 0;
|
||||
|
||||
setInterval(async () => {
|
||||
if (!activeStream || Object.keys(peerConnections).length === 0) return;
|
||||
if (!activeStream || !videoProducer) return;
|
||||
|
||||
// Initialize chart if not present
|
||||
if (!bitrateChart) {
|
||||
@@ -598,16 +587,11 @@ setInterval(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get stats from the first active peer connection
|
||||
const pc = Object.values(peerConnections)[0];
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
const stats = await pc.getStats();
|
||||
const stats = await videoProducer.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;
|
||||
@@ -624,9 +608,8 @@ setInterval(async () => {
|
||||
|
||||
let bitrate = 0;
|
||||
if (lastTimestamp && lastBytesSent) {
|
||||
const timeDiff = timestamp - lastTimestamp; // ms
|
||||
const timeDiff = timestamp - lastTimestamp;
|
||||
const bytesDiff = bytesSent - lastBytesSent;
|
||||
// convert bytes/ms to kbps: (bytes * 8 / 1000) / (timeDiff / 1000) => (bytes * 8) / timeDiff
|
||||
bitrate = Math.round((bytesDiff * 8) / timeDiff);
|
||||
}
|
||||
lastBytesSent = bytesSent;
|
||||
@@ -637,50 +620,70 @@ setInterval(async () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Get audio codec from audio producer stats
|
||||
if (audioProducer) {
|
||||
const audioStats = await audioProducer.getStats();
|
||||
audioStats.forEach(report => {
|
||||
if (report.type === 'codec' && report.mimeType.toLowerCase().includes('audio')) {
|
||||
document.getElementById('statsAudioCodec').innerText = report.mimeType.split('/')[1] || report.mimeType;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Stats error", e); }
|
||||
}, 1000);
|
||||
|
||||
// --- Reusable Audio Dropdown Population ---
|
||||
function populateAudioSelect(audioApps) {
|
||||
const currentValue = audioSelect.value;
|
||||
audioSelect.innerHTML = '<option value="none">No Audio (Video Only)</option>';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const options = Array.from(audioSelect.options);
|
||||
if (options.some(o => o.value === currentValue)) {
|
||||
audioSelect.value = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for live audio source updates from PipeWire monitor
|
||||
window.electronAPI.onAudioAppsUpdated((apps) => {
|
||||
populateAudioSelect(apps);
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Fetch audio applications on startup (this only reads PipeWire, no Wayland portal)
|
||||
// Fetch audio applications on startup
|
||||
(async () => {
|
||||
try {
|
||||
const audioApps = await window.electronAPI.getAudioApps();
|
||||
audioSelect.innerHTML = '<option value="none">No Audio (Video Only)</option>';
|
||||
|
||||
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);
|
||||
});
|
||||
populateAudioSelect(audioApps);
|
||||
} catch (e) {
|
||||
console.error('Failed to load audio apps:', e);
|
||||
audioSelect.innerHTML = '<option value="none">No Audio (Video Only)</option>';
|
||||
}
|
||||
|
||||
// Show the source grid as ready (user can optionally click "Select Sources" for thumbnails)
|
||||
sourcesGrid.innerHTML = '<div style="color:var(--text-secondary); width:100%; text-align:center; padding:1rem;">Click "Start Broadcast" to select a source, or use "Select Sources" for thumbnails.</div>';
|
||||
// Start button is always enabled — source selection happens via the portal
|
||||
sourcesGrid.innerHTML = '<div style="color:var(--text-secondary); width:100%; text-align:center; padding:1rem;"></div>';
|
||||
startBtn.disabled = false;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user