From 0f250d5c2a8c0c8f74a0714d67796592a8646e6e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 23 Feb 2026 16:48:05 +0100 Subject: [PATCH] refactor: Migrate screen sharing media handling from direct WebRTC to Mediasoup. --- .gitignore | 2 + Dockerfile | 6 +- client/index.html | 1 + client/main.js | 23 +- client/mediasoup-entry.js | 4 + client/package-lock.json | 330 +++++++++++++++ client/package.json | 2 + client/pipewire.js | 49 +++ client/preload.js | 3 +- client/renderer.js | 453 ++++++++++---------- docker-compose.yml | 20 +- package-lock.json | 855 ++++++++++++++++++++++++++++++++++++++ package.json | 8 +- public/app.js | 153 ------- public/index.html | 38 -- public/mediasoup-entry.js | 4 + public/viewer.html | 3 +- public/viewer.js | 277 +++++++----- server.js | 268 ++++++++++-- 19 files changed, 1925 insertions(+), 574 deletions(-) create mode 100644 client/mediasoup-entry.js delete mode 100644 public/app.js delete mode 100644 public/index.html create mode 100644 public/mediasoup-entry.js diff --git a/.gitignore b/.gitignore index 9867ed3..8f4e87f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ client/config.json +client/mediasoup-client.bundle.js .env +public/mediasoup-client.js diff --git a/Dockerfile b/Dockerfile index ce93959..2bf59eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM node:20-alpine +FROM node:20 WORKDIR /app COPY package*.json ./ -RUN npm install --production +RUN npm install COPY . . +RUN npx esbuild public/mediasoup-entry.js --bundle --outfile=public/mediasoup-client.js --format=iife --global-name=mediasoupClient --platform=browser EXPOSE 3000 +EXPOSE 40000-49999/udp CMD ["npm", "start"] diff --git a/client/index.html b/client/index.html index 76a914a..0c14306 100644 --- a/client/index.html +++ b/client/index.html @@ -308,6 +308,7 @@ + diff --git a/client/main.js b/client/main.js index 6955956..ab80445 100644 --- a/client/main.js +++ b/client/main.js @@ -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(); }); diff --git a/client/mediasoup-entry.js b/client/mediasoup-entry.js new file mode 100644 index 0000000..6398d7d --- /dev/null +++ b/client/mediasoup-entry.js @@ -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; diff --git a/client/package-lock.json b/client/package-lock.json index bc52d29..1c4d360 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 0f00652..dd9e4b9 100644 --- a/client/package.json +++ b/client/package.json @@ -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" } } diff --git a/client/pipewire.js b/client/pipewire.js index 9f4939f..6249454 100644 --- a/client/pipewire.js +++ b/client/pipewire.js @@ -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; diff --git a/client/preload.js b/client/preload.js index 0533c7c..6661ca0 100644 --- a/client/preload.js +++ b/client/preload.js @@ -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)) }); diff --git a/client/renderer.js b/client/renderer.js index 2fc4008..48a8ccc 100644 --- a/client/renderer.js +++ b/client/renderer.js @@ -1,3 +1,6 @@ +// mediasoupClient is loaded via - - - diff --git a/public/mediasoup-entry.js b/public/mediasoup-entry.js new file mode 100644 index 0000000..7323fdf --- /dev/null +++ b/public/mediasoup-entry.js @@ -0,0 +1,4 @@ +// Entry point for esbuild browser bundle +// This gets bundled into mediasoup-client.js as a global `mediasoupClient` +const mediasoupClient = require('mediasoup-client'); +module.exports = mediasoupClient; diff --git a/public/viewer.html b/public/viewer.html index decec73..4268f1e 100644 --- a/public/viewer.html +++ b/public/viewer.html @@ -17,9 +17,10 @@

Click anywhere to enable audio once connected

- + + diff --git a/public/viewer.js b/public/viewer.js index 72339db..ee19491 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -2,137 +2,196 @@ const socket = io(); const remoteVideo = document.getElementById('remoteVideo'); const overlay = document.getElementById('overlay'); -let peerConnection; -let broadcasterPeerId = null; // Track who the broadcaster is +let device; +let recvTransport; +let consumers = {}; // consumerId -> consumer -// Fetch TURN credentials dynamically from the server -const turnHost = window.location.hostname; -let config = { - iceServers: [ - { urls: `stun:${turnHost}:3478` } - ], - iceCandidatePoolSize: 5 -}; +async function init() { + try { + // 1. Get router RTP capabilities + const rtpCapabilities = await new Promise((resolve, reject) => { + socket.emit('getRouterRtpCapabilities', (data) => { + if (data.error) reject(new Error(data.error)); + else resolve(data); + }); + }); -// Load TURN credentials before any connections -fetch('/turn-config') - .then(r => r.json()) - .then(turn => { - config = { - iceServers: [ - { urls: `stun:${turnHost}:3478` }, - { urls: `turn:${turnHost}:3478`, username: turn.username, credential: turn.credential } - ], - iceCandidatePoolSize: 5 - }; - }) - .catch(e => console.error('Failed to load TURN config:', e)); + // 2. Create mediasoup Device and load capabilities + device = new mediasoupClient.Device(); + await device.load({ routerRtpCapabilities: rtpCapabilities }); -socket.on('offer', (id, description) => { - // Track the broadcaster's socket ID so we only react to THEIR disconnect - broadcasterPeerId = id; - - // Close any existing connection before creating a new one - if (peerConnection) { - peerConnection.close(); - peerConnection = null; + // 3. Create recv transport + const transportParams = await new Promise((resolve, reject) => { + socket.emit('createWebRtcTransport', { direction: 'recv' }, (data) => { + if (data.error) reject(new Error(data.error)); + else resolve(data); + }); + }); + + recvTransport = device.createRecvTransport(transportParams); + + // Transport 'connect' event: DTLS handshake + recvTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { + try { + await new Promise((resolve, reject) => { + socket.emit('connectTransport', { + transportId: recvTransport.id, + dtlsParameters + }, (result) => { + if (result && result.error) reject(new Error(result.error)); + else resolve(); + }); + }); + callback(); + } catch (e) { + errback(e); + } + }); + + // 4. Get existing producers and consume them + const existingProducers = await new Promise((resolve, reject) => { + socket.emit('getProducers', (data) => { + if (data.error) reject(new Error(data.error)); + else resolve(data); + }); + }); + + for (const { producerId, kind } of existingProducers) { + await consumeProducer(producerId, kind); + } + + } catch (e) { + console.error('Failed to initialize mediasoup viewer:', e); } - peerConnection = new RTCPeerConnection(config); - - peerConnection.ontrack = event => { - remoteVideo.srcObject = event.streams[0]; +} + +async function consumeProducer(producerId, kind) { + try { + const result = await new Promise((resolve, reject) => { + socket.emit('consume', { + transportId: recvTransport.id, + producerId, + rtpCapabilities: device.rtpCapabilities + }, (data) => { + if (data.error) reject(new Error(data.error)); + else resolve(data); + }); + }); + + const consumer = await recvTransport.consume({ + id: result.id, + producerId: result.producerId, + kind: result.kind, + rtpParameters: result.rtpParameters + }); + + consumers[consumer.id] = consumer; + + // Attach track to the video element + const { track } = consumer; + + if (!remoteVideo.srcObject) { + remoteVideo.srcObject = new MediaStream(); + } + remoteVideo.srcObject.addTrack(track); + remoteVideo.classList.add('active'); overlay.classList.add('hidden'); - }; - // Auto-unmute when the user interacts with the document to bypass browser autoplay restrictions - document.addEventListener('click', () => { - remoteVideo.muted = false; - remoteVideo.volume = 1.0; - }, {once: true}); - - // Monitor ICE connection state for stability - peerConnection.oniceconnectionstatechange = () => { - console.log('ICE state:', peerConnection.iceConnectionState); - if (peerConnection.iceConnectionState === 'failed') { - console.log('ICE failed, attempting restart...'); - peerConnection.restartIce(); - } else if (peerConnection.iceConnectionState === 'disconnected') { - setTimeout(() => { - if (peerConnection && peerConnection.iceConnectionState === 'disconnected') { - console.log('ICE still disconnected, attempting restart...'); - peerConnection.restartIce(); - } - }, 3000); - } - }; - - peerConnection.onicecandidate = event => { - if (event.candidate) { - socket.emit('candidate', id, event.candidate); - } - }; - - peerConnection - .setRemoteDescription(description) - .then(() => peerConnection.createAnswer()) - .then(sdp => { - 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}`)) { - 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('answer', id, peerConnection.localDescription); + // Resume the consumer (server starts them paused) + await new Promise((resolve, reject) => { + socket.emit('resumeConsumer', { consumerId: consumer.id }, (result) => { + if (result && result.error) reject(new Error(result.error)); + else resolve(); + }); }); -}); -socket.on('candidate', (id, candidate) => { - if (peerConnection) { - peerConnection.addIceCandidate(new RTCIceCandidate(candidate)) - .catch(e => console.error(e)); + consumer.on('trackended', () => { + console.log(`Track ended for consumer ${consumer.id}`); + }); + + consumer.on('transportclose', () => { + console.log(`Transport closed for consumer ${consumer.id}`); + }); + + } catch (e) { + console.error('Failed to consume producer:', e); + } +} + +// Listen for new producers (e.g. broadcaster adds audio after starting) +socket.on('newProducer', async ({ producerId, kind }) => { + if (recvTransport) { + await consumeProducer(producerId, kind); } }); -socket.on('broadcaster', () => { - socket.emit('viewer'); +// Handle producer closed (broadcaster stopped a track) +socket.on('producerClosed', ({ consumerId }) => { + const consumer = consumers[consumerId]; + if (consumer) { + // Remove the track from the video element + if (remoteVideo.srcObject) { + const track = consumer.track; + if (track) { + remoteVideo.srcObject.removeTrack(track); + } + } + consumer.close(); + delete consumers[consumerId]; + } + + // If no more consumers, show overlay + if (Object.keys(consumers).length === 0) { + remoteVideo.classList.remove('active'); + remoteVideo.srcObject = null; + overlay.classList.remove('hidden'); + } }); -// CRITICAL: Only react to the BROADCASTER's disconnect, not other viewers -socket.on('disconnectPeer', (id) => { - if (id !== broadcasterPeerId) return; // Ignore other viewers disconnecting - - if (peerConnection) { - peerConnection.close(); - peerConnection = null; +// Broadcaster connected - try to initialize +socket.on('broadcasterConnected', () => { + // Re-init to pick up new producers + if (!device) { + init(); + } else { + // Just fetch new producers + socket.emit('getProducers', async (producers) => { + for (const { producerId, kind } of producers) { + // Check if we're already consuming this producer + const alreadyConsuming = Object.values(consumers).some(c => c.producerId === producerId); + if (!alreadyConsuming) { + await consumeProducer(producerId, kind); + } + } + }); } - broadcasterPeerId = null; +}); + +// Broadcaster disconnected +socket.on('broadcasterDisconnected', () => { + // Close all consumers + Object.values(consumers).forEach(consumer => { + consumer.close(); + }); + consumers = {}; + remoteVideo.classList.remove('active'); remoteVideo.srcObject = null; - + overlay.classList.remove('hidden'); overlay.querySelector('h1').innerText = 'Stream Ended'; overlay.querySelector('.status-indicator span:last-child').innerText = 'Waiting for new stream...'; }); +// Auto-unmute when the user interacts with the document +document.addEventListener('click', () => { + remoteVideo.muted = false; + remoteVideo.volume = 1.0; +}, { once: true }); + +// Initialize on connect socket.on('connect', () => { socket.emit('viewer'); + init(); }); diff --git a/server.js b/server.js index ac42557..9d22925 100644 --- a/server.js +++ b/server.js @@ -3,62 +3,282 @@ const app = express(); const http = require("http"); const server = http.createServer(app); const { Server } = require("socket.io"); -const io = new Server(server); +const io = new Server(server, { + cors: { + origin: "*", + methods: ["GET", "POST"] + } +}); +const mediasoup = require("mediasoup"); // Password setting via environment variable, defaulting to "secret" const BROADCASTER_PASSWORD = process.env.BROADCASTER_PASSWORD; -const TURN_USER = process.env.TURN_USER || 'myuser'; -const TURN_PASSWORD = process.env.TURN_PASSWORD || 'mypassword'; + +// Default to server IP if run across network; 127.0.0.1 for local testing +const ANNOUNCED_IP = process.env.ANNOUNCED_IP || '127.0.0.1'; + +// --- MEDIASOUP SETUP --- +let worker; +let router; +// State tracking let broadcasterSocketId = null; +let producers = {}; // e.g. { video: producer1, audio: producer2 } +// Map of socket.id -> { transports: {}, consumers: {} } +let clients = {}; -// Serve TURN credentials to clients -app.get('/turn-config', (req, res) => { - res.json({ username: TURN_USER, credential: TURN_PASSWORD }); -}); +// Mediasoup media codecs +const mediaCodecs = [ + { + kind: 'audio', + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2, + parameters: { + useinbandfec: 1, + minptime: 10 + } + }, + { + kind: 'video', + mimeType: 'video/H264', + clockRate: 90000, + parameters: { + 'packetization-mode': 1, + 'profile-level-id': '42e01f', + 'level-asymmetry-allowed': 1 + } + }, + { + kind: 'video', + mimeType: 'video/VP8', + clockRate: 90000, + parameters: {} + } +]; +async function startMediasoup() { + worker = await mediasoup.createWorker({ + logLevel: 'warn', + rtcMinPort: 40000, + rtcMaxPort: 49999, + }); + + worker.on('died', () => { + console.error('mediasoup worker died, exiting in 2 seconds... [pid:%d]', worker.pid); + setTimeout(() => process.exit(1), 2000); + }); + + router = await worker.createRouter({ mediaCodecs }); + console.log("Mediasoup router created."); +} + +// Serve static viewer files app.use(express.static("public")); io.on("connection", (socket) => { console.log("a user connected:", socket.id); + clients[socket.id] = { transports: {}, consumers: {} }; - // When the broadcaster starts sharing + // --- 1. Router Capabilities --- + // Clients need these to initialize their mediasoup-client Device + socket.on("getRouterRtpCapabilities", (callback) => { + try { + callback(router.rtpCapabilities); + } catch (e) { + callback({ error: e.message }); + } + }); + + // --- Broadcaster Auth --- socket.on("broadcaster", (password) => { if (password !== BROADCASTER_PASSWORD) { socket.emit("authError", "Invalid broadcaster password."); return; } broadcasterSocketId = socket.id; - socket.broadcast.emit("broadcaster"); + console.log("Broadcaster authenticated:", socket.id); + socket.broadcast.emit("broadcasterConnected"); }); - // When a viewer joins — notify ONLY the broadcaster, not all sockets - socket.on("viewer", () => { - if (broadcasterSocketId) { - socket.to(broadcasterSocketId).emit("viewer", socket.id); + // --- 2. Create WebRTC Transport --- + socket.on("createWebRtcTransport", async ({ direction }, callback) => { + try { + const transport = await router.createWebRtcTransport({ + listenIps: [{ ip: '0.0.0.0', announcedIp: ANNOUNCED_IP }], + enableUdp: true, + enableTcp: true, + preferUdp: true, + }); + + transport.on("dtlsstatechange", dtlsState => { + if (dtlsState === "closed") transport.close(); + }); + + transport.on("routerclose", () => transport.close()); + + // Store the transport server-side tied to this socket + clients[socket.id].transports[transport.id] = transport; + + // Send parameters back to client to create local mirrored transport + callback({ + id: transport.id, + iceParameters: transport.iceParameters, + iceCandidates: transport.iceCandidates, + dtlsParameters: transport.dtlsParameters, + }); + } catch (e) { + console.error(e); + callback({ error: e.message }); } }); - // WebRTC Signaling - socket.on("offer", (id, message) => { - if (socket.id !== broadcasterSocketId) return; // Prevent hijacking - socket.to(id).emit("offer", socket.id, message); + // --- 3. Connect Transport --- + // Client sends its local DTLS parameters to establish the secure connection + socket.on("connectTransport", async ({ transportId, dtlsParameters }, callback) => { + try { + const transport = clients[socket.id].transports[transportId]; + if (!transport) throw new Error("Transport not found"); + await transport.connect({ dtlsParameters }); + callback(); + } catch (e) { + console.error(e); + callback({ error: e.message }); + } }); - socket.on("answer", (id, message) => { - socket.to(id).emit("answer", socket.id, message); + // --- 4. Produce (Broadcaster sending media TO server) --- + socket.on("produce", async ({ transportId, kind, rtpParameters }, callback) => { + try { + if (socket.id !== broadcasterSocketId) { + throw new Error("Only the authenticated broadcaster can produce media."); + } + + const transport = clients[socket.id].transports[transportId]; + if (!transport) throw new Error("Transport not found"); + + const producer = await transport.produce({ kind, rtpParameters }); + + // Store globally so viewers know what to consume + producers[kind] = producer; + + producer.on("transportclose", () => { + producer.close(); + }); + + // Notify ALL existing viewers that a new track is available + socket.broadcast.emit("newProducer", { producerId: producer.id, kind: producer.kind }); + + // Return the ID back to the broadcaster client + callback({ id: producer.id }); + } catch (e) { + console.error(e); + callback({ error: e.message }); + } }); - socket.on("candidate", (id, message) => { - socket.to(id).emit("candidate", socket.id, message); + // --- 5. Consume (Viewers receiving media FROM server) --- + socket.on("consume", async ({ transportId, producerId, rtpCapabilities }, callback) => { + try { + const transport = clients[socket.id].transports[transportId]; + if (!transport) throw new Error("Transport not found"); + + if (!router.canConsume({ producerId, rtpCapabilities })) { + throw new Error("Client cannot consume this producer."); + } + + const consumer = await transport.consume({ + producerId, + rtpCapabilities, + paused: true, // important: start paused until client confirms ready + }); + + clients[socket.id].consumers[consumer.id] = consumer; + + consumer.on("transportclose", () => { + consumer.close(); + }); + + consumer.on("producerclose", () => { + socket.emit("producerClosed", { consumerId: consumer.id }); + consumer.close(); + delete clients[socket.id].consumers[consumer.id]; + }); + + callback({ + id: consumer.id, + producerId: consumer.producerId, + kind: consumer.kind, + rtpParameters: consumer.rtpParameters, + }); + } catch (e) { + console.error(e); + callback({ error: e.message }); + } + }); + + // Client says they successfully created the local consumer, we can resume sending RTP + socket.on("resumeConsumer", async ({ consumerId }, callback) => { + try { + const consumer = clients[socket.id].consumers[consumerId]; + if (!consumer) throw new Error("Consumer not found"); + await consumer.resume(); + callback(); + } catch (e) { + console.error(e); + callback({ error: e.message }); + } + }); + + // Helper for new viewers: "What streams are currently running?" + socket.on("getProducers", (callback) => { + // Return array of currently active producer IDs and their kinds + const activeProducers = Object.values(producers) + .filter(p => !p.closed) + .map(p => ({ + producerId: p.id, + kind: p.kind + })); + callback(activeProducers); + }); + + // Viewer count: return number of connected viewers (non-broadcaster sockets) + socket.on("getViewerCount", (callback) => { + const count = Object.keys(clients).filter(id => id !== broadcasterSocketId).length; + callback(count); }); socket.on("disconnect", () => { console.log("user disconnected", socket.id); - socket.broadcast.emit("disconnectPeer", socket.id); + + // If the broadcaster disconnected, clean up producers + if (socket.id === broadcasterSocketId) { + Object.keys(producers).forEach(kind => { + producers[kind].close(); + }); + producers = {}; + broadcasterSocketId = null; + socket.broadcast.emit("broadcasterDisconnected"); + } + + // Clean up this client's transports and consumers + if (clients[socket.id]) { + Object.values(clients[socket.id].transports).forEach(t => t.close()); + delete clients[socket.id]; + } + + // Notify broadcaster about updated viewer count + if (broadcasterSocketId && io.sockets.sockets.get(broadcasterSocketId)) { + const count = Object.keys(clients).filter(id => id !== broadcasterSocketId).length; + io.to(broadcasterSocketId).emit("viewerCount", count); + } }); }); const PORT = process.env.PORT || 3000; -server.listen(PORT, () => { - console.log(`listening on *:${PORT}`); + +startMediasoup().then(() => { + server.listen(PORT, () => { + console.log(`Mediasoup SFU listening on *:${PORT}`); + console.log(`NOTE: If running on a network, set ANNOUNCED_IP to the server's public IP`); + }); });