refactor: Migrate screen sharing media handling from direct WebRTC to Mediasoup.

This commit is contained in:
2026-02-23 16:48:05 +01:00
parent ff013b206a
commit 0f250d5c2a
19 changed files with 1925 additions and 574 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/
client/config.json
client/mediasoup-client.bundle.js
.env
public/mediasoup-client.js

View File

@@ -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"]

View File

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

View File

@@ -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();
});

View 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
View File

@@ -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",

View File

@@ -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"
}
}

View File

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

View File

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

View File

@@ -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
});
// --- 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.on('connect', async () => {
try {
// 1. Authenticate as broadcaster
socket.emit('broadcaster', password);
// 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);
});
});
// 3. Create mediasoup Device and load capabilities
device = new Device();
await device.load({ routerRtpCapabilities: rtpCapabilities });
// 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);
});
});
sendTransport = device.createSendTransport(transportParams);
// 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);
}
});
// 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;
});
}
// 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('authError', (msg) => {
alert(msg);
stopSharing();
reject(new Error(msg));
});
socket.on('viewer', id => {
if (!activeStream) return;
const peerConnection = new RTCPeerConnection(config);
peerConnections[id] = peerConnection;
updateViewerCount();
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));
}
socket.on('connect_error', (err) => {
console.error('Socket connection error:', err);
});
peerConnection.onicecandidate = event => {
if (event.candidate) {
socket.emit('candidate', id, event.candidate);
}
};
// 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();
}
}, 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);
});
}
}
// 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);
});
});
socket.on('answer', (id, description) => {
if (peerConnections[id]) peerConnections[id].setRemoteDescription(description);
});
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();
}
});
}
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,30 +620,29 @@ 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);
// 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)
(async () => {
try {
const audioApps = await window.electronAPI.getAudioApps();
// --- 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');
@@ -674,13 +656,34 @@ window.electronAPI.getConfig().then(cfg => {
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
(async () => {
try {
const audioApps = await window.electronAPI.getAudioApps();
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;
})();

View File

@@ -5,24 +5,8 @@ services:
build: .
ports:
- "3000:3000"
- "40000-49999:40000-49999/udp"
restart: always
environment:
- BROADCASTER_PASSWORD=${BROADCASTER_PASSWORD}
- TURN_USER=${TURN_USER}
- TURN_PASSWORD=${TURN_PASSWORD}
- TURN_REALM=${TURN_REALM}
coturn:
image: coturn/coturn:latest
network_mode: "host"
restart: always
command: >
-n
--log-file=stdout
--listening-ip=0.0.0.0
--external-ip=${TURN_EXTERNAL_IP}
--min-port=49152
--max-port=49252
--realm=${TURN_REALM}
--user=${TURN_USER}:${TURN_PASSWORD}
--stale-nonce
- ANNOUNCED_IP=${ANNOUNCED_IP}

855
package-lock.json generated
View File

@@ -9,7 +9,436 @@
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"mediasoup": "^3.19.17",
"mediasoup-client": "^3.18.7",
"socket.io": "^4.7.4"
},
"devDependencies": {
"esbuild": "^0.21.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"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/@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/@socket.io/component-emitter": {
@@ -27,6 +456,28 @@
"@types/node": "*"
}
},
"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/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": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
@@ -55,6 +506,45 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"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/awaitqueue/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/awaitqueue/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/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -126,6 +616,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -179,6 +678,15 @@
"url": "https://opencollective.com/express"
}
},
"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": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -318,6 +826,45 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -333,6 +880,16 @@
"node": ">= 0.6"
}
},
"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/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -379,6 +936,41 @@
"url": "https://opencollective.com/express"
}
},
"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/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/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -397,6 +989,24 @@
"node": ">= 0.8"
}
},
"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/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -473,6 +1083,45 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/h264-profile-level-id/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/h264-profile-level-id/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/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -562,6 +1211,98 @@
"node": ">= 0.6"
}
},
"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/mediasoup-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mediasoup-client/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/mediasoup/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/mediasoup/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/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -613,6 +1354,27 @@
"node": ">= 0.6"
}
},
"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.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -628,6 +1390,44 @@
"node": ">= 0.6"
}
},
"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/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -754,6 +1554,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"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/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -996,6 +1805,34 @@
"node": ">= 0.8"
}
},
"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/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1051,6 +1888,15 @@
"node": ">= 0.8"
}
},
"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/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
@@ -1071,6 +1917,15 @@
"optional": true
}
}
},
"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"
}
}
}
}

View File

@@ -5,10 +5,16 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"bundle-client": "esbuild public/mediasoup-entry.js --bundle --outfile=public/mediasoup-client.js --format=iife --global-name=mediasoupClient --platform=browser"
},
"dependencies": {
"express": "^4.18.2",
"mediasoup": "^3.19.17",
"mediasoup-client": "^3.18.7",
"socket.io": "^4.7.4"
},
"devDependencies": {
"esbuild": "^0.21.0"
}
}

View File

@@ -1,153 +0,0 @@
const socket = io();
const peerConnections = {};
const startBtn = document.getElementById('startBtn');
const localVideo = document.getElementById('localVideo');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const passwordInput = document.getElementById('broadcasterPassword');
const authContainer = document.getElementById('authContainer');
let activeStream;
const config = {
iceServers: [
{ urls: "stun:localhost:3478" },
{
urls: "turn:localhost:3478",
username: "myuser",
credential: "mypassword"
}
]
};
socket.on('authError', (msg) => {
alert(msg);
stopSharing();
});
socket.on('viewer', id => {
if (!activeStream) return;
const peerConnection = new RTCPeerConnection(config);
peerConnections[id] = peerConnection;
activeStream.getTracks().forEach(track => {
const sender = peerConnection.addTrack(track, activeStream);
// Force high bitrate for best possible 1080p60 quality
if (track.kind === 'video') {
const params = sender.getParameters();
if (!params.encodings) params.encodings = [{}];
// Set max bandwidth to 10 Mbps (10,000,000 bps)
params.encodings[0].maxBitrate = 10000000;
sender.setParameters(params).catch(e => console.error("Could not set high bitrate:", e));
}
});
peerConnection.onicecandidate = event => {
if (event.candidate) {
socket.emit('candidate', id, event.candidate);
}
};
peerConnection
.createOffer()
.then(sdp => {
// Force VP8/H264 on the SDP offer to maximize compatibility with Chromium
if (window.RTCRtpSender && window.RTCRtpSender.getCapabilities) {
const capabilities = window.RTCRtpSender.getCapabilities('video');
if (capabilities && capabilities.codecs) {
const preferredCodecs = capabilities.codecs.filter(c =>
c.mimeType.toLowerCase() === 'video/vp8' ||
c.mimeType.toLowerCase() === 'video/h264'
);
if (preferredCodecs.length > 0) {
const transceivers = peerConnection.getTransceivers();
transceivers.forEach(transceiver => {
if (transceiver.receiver.track.kind === 'video') {
try {
transceiver.setCodecPreferences(preferredCodecs);
} catch (e) {
console.warn('Failed to set codec preferences:', e);
}
}
});
}
}
}
return peerConnection.setLocalDescription(sdp);
})
.then(() => {
socket.emit('offer', id, peerConnection.localDescription);
});
});
socket.on('answer', (id, description) => {
if (peerConnections[id]) {
peerConnections[id].setRemoteDescription(description);
}
});
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];
}
});
startBtn.addEventListener('click', async () => {
const password = passwordInput.value;
if (!password) {
alert("Please enter the stream password to broadcast.");
return;
}
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 60 }
},
audio: false
});
activeStream = stream;
localVideo.srcObject = stream;
localVideo.classList.add('active');
startBtn.style.display = 'none';
authContainer.style.display = 'none';
statusDot.classList.add('active');
statusText.innerText = 'Sharing Screen (1080p60)';
socket.emit('broadcaster', password);
stream.getVideoTracks()[0].onended = () => {
stopSharing();
};
} catch (err) {
console.error("Error accessing display media.", err);
statusText.innerText = 'Failed to access screen';
statusDot.style.backgroundColor = 'var(--error-color)';
}
});
function stopSharing() {
startBtn.style.display = 'inline-block';
authContainer.style.display = 'block';
localVideo.classList.remove('active');
statusDot.classList.remove('active');
statusText.innerText = 'Not Sharing';
activeStream = null;
socket.emit('disconnect');
}

View File

@@ -1,38 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Screenshare - Broadcaster</title>
<link rel="stylesheet" href="style.css" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div class="container glass">
<h1>Share Your Screen</h1>
<p>Broadcast your screen at 1080p 60fps to viewers.</p>
<div id="authContainer" style="margin-bottom: 1.5rem;">
<input type="password" id="broadcasterPassword" placeholder="Enter stream password" style="padding: 0.8rem 1rem; border-radius: 8px; border: 1px solid var(--glass-border); background: rgba(0,0,0,0.2); color: white; width: 100%; max-width: 300px; font-family: inherit; font-size: 1rem; outline: none;">
</div>
<button id="startBtn" class="btn primary-btn">Start Screenshare</button>
<div class="status-indicator">
<span class="dot" id="statusDot"></span>
<span id="statusText">Not Sharing</span>
</div>
<video id="localVideo" autoplay playsinline muted></video>
<div class="info-section">
<p>
Viewers can watch at:
<a href="/viewer.html" target="_blank" class="accent-link"
>/viewer.html</a
>
</p>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

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

View File

@@ -17,9 +17,10 @@
</div>
<p style="margin-top:20px; font-size: 0.9rem; color: var(--text-secondary);">Click anywhere to enable audio once connected</p>
</div>
<video id="remoteVideo" autoplay playsinline controls></video>
<video id="remoteVideo" autoplay playsinline controls muted></video>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="mediasoup-client.js"></script>
<script src="viewer.js"></script>
</body>
</html>

View File

@@ -2,129 +2,180 @@ 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;
// 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);
});
});
// Close any existing connection before creating a new one
if (peerConnection) {
peerConnection.close();
peerConnection = null;
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);
}
peerConnection = new RTCPeerConnection(config);
});
// 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);
}
}
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);
peerConnection.ontrack = event => {
remoteVideo.srcObject = event.streams[0];
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;
@@ -133,6 +184,14 @@ socket.on('disconnectPeer', (id) => {
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();
});

266
server.js
View File

@@ -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';
let broadcasterSocketId = null;
// Serve TURN credentials to clients
app.get('/turn-config', (req, res) => {
res.json({ username: TURN_USER, credential: TURN_PASSWORD });
// 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 = {};
// 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();
});
socket.on("candidate", (id, message) => {
socket.to(id).emit("candidate", socket.id, message);
// 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 });
}
});
// --- 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;
startMediasoup().then(() => {
server.listen(PORT, () => {
console.log(`listening on *:${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`);
});
});