mediasfu-shared 1.2.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/HEADLESS_GUIDE.md +12 -0
  3. package/README.md +14 -2
  4. package/dist/ProducerClient/producerClientEmits/roomVideoPolicy.d.ts +20 -0
  5. package/dist/ProducerClient/producerClientEmits/roomVideoPolicy.d.ts.map +1 -0
  6. package/dist/ProducerClient/producerClientEmits/updateRoomParametersClient.d.ts +11 -1
  7. package/dist/ProducerClient/producerClientEmits/updateRoomParametersClient.d.ts.map +1 -1
  8. package/dist/audioProcessing-Ca5wm1nV.js +150 -0
  9. package/dist/audioProcessing-Ca5wm1nV.js.map +1 -0
  10. package/dist/audioProcessing-VCbu70Jt.cjs +149 -0
  11. package/dist/audioProcessing-VCbu70Jt.cjs.map +1 -0
  12. package/dist/consumers/closeAndResize.d.ts +3 -0
  13. package/dist/consumers/closeAndResize.d.ts.map +1 -1
  14. package/dist/consumers/connectIps.d.ts +6 -0
  15. package/dist/consumers/connectIps.d.ts.map +1 -1
  16. package/dist/consumers/connectSendTransportScreenAudio.d.ts +65 -0
  17. package/dist/consumers/connectSendTransportScreenAudio.d.ts.map +1 -0
  18. package/dist/consumers/consumerBackupPreference.d.ts +14 -0
  19. package/dist/consumers/consumerBackupPreference.d.ts.map +1 -0
  20. package/dist/consumers/disconnectSendTransportScreen.d.ts.map +1 -1
  21. package/dist/consumers/getPipedProducersAlt.d.ts.map +1 -1
  22. package/dist/consumers/index.cjs +23 -1
  23. package/dist/consumers/index.cjs.map +1 -1
  24. package/dist/consumers/index.d.ts +3 -0
  25. package/dist/consumers/index.d.ts.map +1 -1
  26. package/dist/consumers/index.js +61 -39
  27. package/dist/consumers/index.js.map +1 -1
  28. package/dist/consumers/screenAudio.d.ts +64 -0
  29. package/dist/consumers/screenAudio.d.ts.map +1 -0
  30. package/dist/consumers/socketReceiveMethods/producerClosed.d.ts.map +1 -1
  31. package/dist/consumers/startShareScreen.d.ts +14 -0
  32. package/dist/consumers/startShareScreen.d.ts.map +1 -1
  33. package/dist/consumers/streamSuccessScreen.d.ts.map +1 -1
  34. package/dist/{getParticipantMedia-B6LsMxmS.cjs → getParticipantMedia-1okyF4H7.cjs} +4 -4
  35. package/dist/{getParticipantMedia-B6LsMxmS.cjs.map → getParticipantMedia-1okyF4H7.cjs.map} +1 -1
  36. package/dist/{getParticipantMedia-B8MFkrT1.js → getParticipantMedia-DEJwXXhU.js} +4 -4
  37. package/dist/{getParticipantMedia-B8MFkrT1.js.map → getParticipantMedia-DEJwXXhU.js.map} +1 -1
  38. package/dist/index.cjs +36 -6
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1523 -1493
  43. package/dist/index.native.cjs +36 -6
  44. package/dist/index.native.cjs.map +1 -1
  45. package/dist/index.native.d.ts +4 -0
  46. package/dist/index.native.d.ts.map +1 -1
  47. package/dist/index.native.js +105 -75
  48. package/dist/{joinLocalRoom-X3NXzGmE.js → joinLocalRoom-DS6_6lJL.js} +26 -23
  49. package/dist/joinLocalRoom-DS6_6lJL.js.map +1 -0
  50. package/dist/{joinLocalRoom-XjGmL1_k.cjs → joinLocalRoom-Dsn39SwS.cjs} +26 -23
  51. package/dist/joinLocalRoom-Dsn39SwS.cjs.map +1 -0
  52. package/dist/{joinRoomOnMediaSFU-BnAWLciF.js → joinRoomOnMediaSFU-B6tTA0Mx.js} +17 -13
  53. package/dist/joinRoomOnMediaSFU-B6tTA0Mx.js.map +1 -0
  54. package/dist/{joinRoomOnMediaSFU-D2C7q1GK.cjs → joinRoomOnMediaSFU-CxwD9fBz.cjs} +17 -13
  55. package/dist/joinRoomOnMediaSFU-CxwD9fBz.cjs.map +1 -0
  56. package/dist/methods/index.cjs +12 -8
  57. package/dist/methods/index.cjs.map +1 -1
  58. package/dist/methods/index.js +14 -9
  59. package/dist/methods/index.js.map +1 -1
  60. package/dist/methods/socketReceive/getDomains.d.ts.map +1 -1
  61. package/dist/methods/utils/checkLimitsAndMakeRequest.d.ts.map +1 -1
  62. package/dist/methods/utils/createResponseJoinRoom.d.ts.map +1 -1
  63. package/dist/methods/welcome/handleWelcomeRequest.d.ts.map +1 -1
  64. package/dist/sockets/SocketManager.d.ts.map +1 -1
  65. package/dist/sockets/mediaSocketErrors.d.ts +62 -0
  66. package/dist/sockets/mediaSocketErrors.d.ts.map +1 -0
  67. package/dist/types/index.cjs +4 -3
  68. package/dist/types/index.cjs.map +1 -1
  69. package/dist/types/index.js +39 -38
  70. package/dist/types/shared-base-types.d.ts +8 -0
  71. package/dist/types/shared-base-types.d.ts.map +1 -1
  72. package/dist/{updateParticipantAudioDecibels-DiJ_LFfH.js → updateParticipantAudioDecibels-BBfdHWQ0.js} +344 -61
  73. package/dist/updateParticipantAudioDecibels-BBfdHWQ0.js.map +1 -0
  74. package/dist/{updateParticipantAudioDecibels-BJ2PxJJD.cjs → updateParticipantAudioDecibels-Dni8i6AY.cjs} +306 -23
  75. package/dist/updateParticipantAudioDecibels-Dni8i6AY.cjs.map +1 -0
  76. package/package.json +3 -2
  77. package/src/ProducerClient/producerClientEmits/roomVideoPolicy.ts +41 -0
  78. package/src/ProducerClient/producerClientEmits/updateRoomParametersClient.ts +19 -21
  79. package/src/consumers/closeAndResize.ts +14 -0
  80. package/src/consumers/connectIps.ts +81 -8
  81. package/src/consumers/connectSendTransportScreenAudio.ts +184 -0
  82. package/src/consumers/consumerBackupPreference.ts +43 -0
  83. package/src/consumers/disconnectSendTransportScreen.ts +8 -0
  84. package/src/consumers/getPipedProducersAlt.ts +4 -0
  85. package/src/consumers/index.ts +3 -0
  86. package/src/consumers/screenAudio.ts +132 -0
  87. package/src/consumers/socketReceiveMethods/producerClosed.ts +3 -0
  88. package/src/consumers/startShareScreen.ts +44 -8
  89. package/src/consumers/streamSuccessScreen.ts +11 -0
  90. package/src/index.native.ts +4 -0
  91. package/src/index.ts +4 -0
  92. package/src/methods/socketReceive/getDomains.ts +4 -0
  93. package/src/methods/utils/checkLimitsAndMakeRequest.ts +24 -11
  94. package/src/methods/utils/createResponseJoinRoom.ts +2 -1
  95. package/src/methods/welcome/handleWelcomeRequest.ts +15 -6
  96. package/src/sockets/SocketManager.ts +76 -12
  97. package/src/sockets/mediaSocketErrors.ts +156 -0
  98. package/src/types/shared-base-types.ts +8 -0
  99. package/dist/audioProcessing-0z5KlcmO.js +0 -35
  100. package/dist/audioProcessing-0z5KlcmO.js.map +0 -1
  101. package/dist/audioProcessing-BpbrdCk-.cjs +0 -34
  102. package/dist/audioProcessing-BpbrdCk-.cjs.map +0 -1
  103. package/dist/joinLocalRoom-X3NXzGmE.js.map +0 -1
  104. package/dist/joinLocalRoom-XjGmL1_k.cjs.map +0 -1
  105. package/dist/joinRoomOnMediaSFU-BnAWLciF.js.map +0 -1
  106. package/dist/joinRoomOnMediaSFU-D2C7q1GK.cjs.map +0 -1
  107. package/dist/updateParticipantAudioDecibels-BJ2PxJJD.cjs.map +0 -1
  108. package/dist/updateParticipantAudioDecibels-DiJ_LFfH.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mediasfu-shared",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "mediasfu-shared – framework-agnostic WebRTC runtime for MediaSFU. Room helpers, mediasoup signaling, socket management, media state, and TypeScript types for React, Vue, Angular, Svelte, and plain TS.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -45,7 +45,8 @@
45
45
  "build-docs": "typedoc",
46
46
  "dev": "vite build --watch",
47
47
  "test:staging:smoke": "npm run build && node ./scripts/staging-room-smoke-test.cjs",
48
- "type-check": "tsc --noEmit"
48
+ "type-check": "tsc --noEmit",
49
+ "test:sdk-parity": "node tests/sdkParity.test.cjs"
49
50
  },
50
51
  "keywords": [
51
52
  "mediasfu",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Camera-only policy. Screen-sharing and audio encodings are unchanged.
3
+ * Server capacity wins over a hint; recording does not increase camera bitrate.
4
+ */
5
+ export interface CameraEncodingPolicy {
6
+ simulcastOff?: boolean;
7
+ roomCapacity?: number;
8
+ }
9
+
10
+ export function cameraSimulcastOff(
11
+ eventType: string,
12
+ capacity: number | undefined,
13
+ options: CameraEncodingPolicy = {},
14
+ ): boolean {
15
+ const roomCapacity = capacity ?? options.roomCapacity;
16
+ return options.simulcastOff === true || eventType === 'chat' || roomCapacity === 2;
17
+ }
18
+
19
+ type CameraEncoding = {
20
+ rid?: string;
21
+ scaleResolutionDownBy?: number;
22
+ scalabilityMode?: string;
23
+ };
24
+
25
+ /** Mutate only the fresh room preset, preserving its original-preset lineage. */
26
+ export function applyCameraEncodingPolicy<T extends { encodings: CameraEncoding[] }>(
27
+ params: T,
28
+ singleEncoding: boolean,
29
+ ): T {
30
+ if (!singleEncoding || !params.encodings.length) return params;
31
+ // Presets conventionally put the full-resolution layer last. Do not assume
32
+ // that order for custom presets; select the least downscaled encoding.
33
+ const full = params.encodings.reduce((best, encoding) =>
34
+ (encoding.scaleResolutionDownBy ?? 1) <= (best.scaleResolutionDownBy ?? 1)
35
+ ? encoding : best);
36
+ const encoding = { ...full, scaleResolutionDownBy: 1 };
37
+ delete encoding.rid;
38
+ if (encoding.scalabilityMode) encoding.scalabilityMode = 'L1T3';
39
+ params.encodings = [encoding];
40
+ return params;
41
+ }
@@ -1,3 +1,4 @@
1
+ import { applyCameraEncodingPolicy, cameraSimulcastOff } from './roomVideoPolicy';
1
2
  /* eslint-disable eqeqeq */
2
3
  import { RtpCapabilities } from 'mediasoup-client/types';
3
4
  import {
@@ -69,6 +70,10 @@ export interface UpdateRoomParametersClientParameters {
69
70
  islevel: string;
70
71
  showAlert?: ShowAlert;
71
72
  data: ResponseJoinRoom;
73
+ /** Disable camera simulcast even in larger rooms. */
74
+ simulcastOff?: boolean;
75
+ /** Capacity hint used only when the server does not return capacity. */
76
+ roomCapacity?: number;
72
77
  updateRtpCapabilities: (rtpCapabilities: RtpCapabilities | null) => void;
73
78
  updateRoomRecvIPs: (roomRecvIPs: string[]) => void;
74
79
  updateMeetingRoomParams: (meetingRoomParams: MeetingRoomParams | null) => void;
@@ -112,17 +117,23 @@ export interface UpdateRoomParametersClientParameters {
112
117
  updateRecordingPreferredOrientation: (orientation: string) => void;
113
118
  updateRecordingSupportForOtherOrientation: (support: boolean) => void;
114
119
  updateRecordingMultiFormatsSupport: (support: boolean) => void;
120
+ /** Records whether the media server accepts screen-share audio (join response `supportsScreenAudio`). */
121
+ updateSupportsScreenAudio?: (supported: boolean) => void;
115
122
  updateRecordingVideoOptions: (options: string) => void;
116
123
  updateRecordingAudioOptions: (options: string) => void;
117
124
  }
118
125
 
119
126
  export type UpdateRoomParametersClientOptions = {
120
127
  parameters: UpdateRoomParametersClientParameters;
128
+ /** Disable camera simulcast even in larger rooms. */
129
+ simulcastOff?: boolean;
130
+ /** Capacity hint used only when the server does not return capacity. */
131
+ roomCapacity?: number;
121
132
  };
122
133
 
123
134
  export type UpdateRoomParametersClientType = (options: UpdateRoomParametersClientOptions) => void;
124
135
 
125
- export const updateRoomParametersClient = ({ parameters }: UpdateRoomParametersClientOptions): void => {
136
+ export const updateRoomParametersClient = ({ parameters, simulcastOff = parameters.simulcastOff, roomCapacity = parameters.roomCapacity }: UpdateRoomParametersClientOptions): void => {
126
137
  try {
127
138
  const {
128
139
  screenPageLimit,
@@ -175,6 +186,7 @@ export const updateRoomParametersClient = ({ parameters }: UpdateRoomParametersC
175
186
  updateRecordingPreferredOrientation,
176
187
  updateRecordingSupportForOtherOrientation,
177
188
  updateRecordingMultiFormatsSupport,
189
+ updateSupportsScreenAudio,
178
190
  updateRecordingVideoOptions,
179
191
  updateRecordingAudioOptions,
180
192
  updateMainHeightWidth,
@@ -212,6 +224,7 @@ export const updateRoomParametersClient = ({ parameters }: UpdateRoomParametersC
212
224
  updateRecordingPreferredOrientation(data.recordingParams.recordingPreferredOrientation);
213
225
  updateRecordingSupportForOtherOrientation(data.recordingParams.recordingSupportForOtherOrientation);
214
226
  updateRecordingMultiFormatsSupport(data.recordingParams.recordingMultiFormatsSupport);
227
+ updateSupportsScreenAudio?.(data.supportsScreenAudio === true);
215
228
 
216
229
  updateItemPageLimit(data.meetingRoomParams.itemPageLimit);
217
230
  updateEventType(data.meetingRoomParams.type);
@@ -354,26 +367,11 @@ export const updateRoomParametersClient = ({ parameters }: UpdateRoomParametersC
354
367
  });
355
368
  }
356
369
 
357
- if (data.recordingParams.recordingVideoSupport) {
358
- vParamsValue.encodings.forEach((encoding: Partial<VParamsType['encodings'][0]>) => {
359
- if (encoding.maxBitrate) {
360
- encoding.maxBitrate = Math.floor(encoding.maxBitrate * 1.2);
361
- }
362
- });
363
-
364
- hParamsValue.encodings.forEach((encoding: Partial<HParamsType['encodings'][0]>) => {
365
- if (encoding.maxBitrate) {
366
- encoding.maxBitrate = Math.floor(encoding.maxBitrate * 1.2);
367
- }
368
- });
369
-
370
- if (hParamsValue.codecOptions && hParamsValue.codecOptions.videoGoogleStartBitrate) {
371
- hParamsValue.codecOptions.videoGoogleStartBitrate = Math.floor(hParamsValue.codecOptions.videoGoogleStartBitrate * 1.2);
372
- }
373
- if (vParamsValue.codecOptions && vParamsValue.codecOptions.videoGoogleStartBitrate) {
374
- vParamsValue.codecOptions.videoGoogleStartBitrate = Math.floor(vParamsValue.codecOptions.videoGoogleStartBitrate * 1.2);
375
- }
376
- }
370
+ const singleCameraEncoding = cameraSimulcastOff(data.meetingRoomParams.type, data.capacity, {
371
+ simulcastOff, roomCapacity,
372
+ });
373
+ applyCameraEncodingPolicy(hParamsValue, singleCameraEncoding);
374
+ applyCameraEncodingPolicy(vParamsValue, singleCameraEncoding);
377
375
 
378
376
  updateVidCons(vidCons);
379
377
  updateFrameRate(frameRateValue);
@@ -8,6 +8,8 @@ import {
8
8
 
9
9
  export interface CloseAndResizeParameters extends ReorderStreamsParameters, PrepopulateUserMediaParameters, RePortParameters {
10
10
  allAudioStreams: (Stream | Participant)[];
11
+ /** Rendered audio-only players (React elements, Vue/Angular component descriptors). */
12
+ audioOnlyStreams?: any[];
11
13
  allVideoStreams: (Stream | Participant)[];
12
14
  activeNames: string[];
13
15
  participants: Participant[];
@@ -32,6 +34,7 @@ export interface CloseAndResizeParameters extends ReorderStreamsParameters, Prep
32
34
  updateMainWindow: boolean;
33
35
  updateActiveNames: (activeNames: string[]) => void;
34
36
  updateAllAudioStreams: (allAudioStreams: (Stream | Participant)[]) => void;
37
+ updateAudioOnlyStreams?: (audioOnlyStreams: any[]) => void;
35
38
  updateShareScreenStarted: (shareScreenStarted: boolean) => void;
36
39
  updateUpdateMainWindow: (updateMainWindow: boolean) => void;
37
40
  updateNewLimitedStreams: (newLimitedStreams: (Stream | Participant)[]) => void;
@@ -176,6 +179,7 @@ export const closeAndResize = async ({ producerId, kind, parameters }: CloseAndR
176
179
 
177
180
  let {
178
181
  allAudioStreams,
182
+ audioOnlyStreams = [],
179
183
  allVideoStreams,
180
184
  activeNames,
181
185
  participants,
@@ -201,6 +205,7 @@ export const closeAndResize = async ({ producerId, kind, parameters }: CloseAndR
201
205
  updateMainWindow,
202
206
  updateActiveNames,
203
207
  updateAllAudioStreams,
208
+ updateAudioOnlyStreams,
204
209
  updateAllVideoStreams,
205
210
 
206
211
  updateShareScreenStarted,
@@ -235,6 +240,15 @@ export const closeAndResize = async ({ producerId, kind, parameters }: CloseAndR
235
240
 
236
241
  updateAllAudioStreams(allAudioStreams);
237
242
 
243
+ // Audio-only players (mic and screen audio) are stored separately from allAudioStreams. Leaving one mounted after
244
+ // its producer closes means a rejoin adds a second player, heard as echo or duplicate speech.
245
+ const playerProducerId = (element: any): string =>
246
+ String(element?.props?.remoteProducerId ?? element?.inputs?.remoteProducerId ?? element?.key ?? '');
247
+ if (audioOnlyStreams.some((element: any) => playerProducerId(element) === producerId)) {
248
+ audioOnlyStreams = audioOnlyStreams.filter((element: any) => playerProducerId(element) !== producerId);
249
+ updateAudioOnlyStreams?.(audioOnlyStreams);
250
+ }
251
+
238
252
  if (recordingDisplayType == "video" && recordingVideoOptimized == true) {
239
253
  // optimize the video display
240
254
  } else {
@@ -1,8 +1,11 @@
1
1
 
2
- import { connectSocket } from "../../src/sockets/SocketManager";
2
+ import { connectSocket } from "../sockets/SocketManager";
3
3
  import { newPipeProducer } from "./socketReceiveMethods/newPipeProducer";
4
4
  import { producerClosed } from "./socketReceiveMethods/producerClosed";
5
5
  import { joinConsumeRoom } from "./socketReceiveMethods/joinConsumeRoom";
6
+ import { markScreenAudioProducer } from "./screenAudio";
7
+ import { consumerBackupPreferDelay } from "./consumerBackupPreference";
8
+ import { isMediaSocketConnectError } from "../sockets/mediaSocketErrors";
6
9
  import type { Device } from 'mediasoup-client/types';
7
10
  import {
8
11
  ReorderStreamsParameters, ReorderStreamsType, NewPipeProducerParameters, NewPipeProducerType, ProducerClosedType,
@@ -36,9 +39,9 @@ export interface ConnectIpsOptions {
36
39
  // Export the type definition for the function
37
40
  export type ConnectIpsType = (options: ConnectIpsOptions) => Promise<[Record<string, any>[], string[]]>;
38
41
 
39
- // Keep the reservation local to one room engine's socket collection. Separate
40
- // rooms may legitimately consume from the same endpoint and must not share a
41
- // socket, while overlapping updates for one room must serialize that endpoint.
42
+ // Scope pending connections to the caller's socket collection. This prevents
43
+ // overlapping room/domain updates from opening the same consume endpoint more
44
+ // than once without sharing sockets between independent room engines.
42
45
  const pendingConsumeConnections = new WeakMap<
43
46
  object,
44
47
  Map<string, Promise<boolean>>
@@ -47,6 +50,28 @@ const pendingConsumeConnections = new WeakMap<
47
50
  const normalizeConsumeEndpoint = (ip: string): string =>
48
51
  ip.trim().toLowerCase().replace(/\.$/, "");
49
52
 
53
+ /**
54
+ * Consume endpoints that refused these credentials (`connection-rejected` with a permanent reason). Domain updates call
55
+ * connectIps again and again; without this every update would retry the same refusal. Keyed by the caller's socket
56
+ * collection so another room (new credentials) starts clean.
57
+ */
58
+ export const CONSUME_REJECTION_COOLDOWN_MS = 60000;
59
+ const rejectedConsumeEndpoints = new WeakMap<object, Map<string, number>>();
60
+
61
+ const consumeEndpointRecentlyRejected = (owner: object, endpoint: string): boolean => {
62
+ const at = rejectedConsumeEndpoints.get(owner)?.get(endpoint);
63
+ return at !== undefined && Date.now() - at < CONSUME_REJECTION_COOLDOWN_MS;
64
+ };
65
+
66
+ const rememberConsumeEndpointRejection = (owner: object, endpoint: string): void => {
67
+ let byEndpoint = rejectedConsumeEndpoints.get(owner);
68
+ if (!byEndpoint) {
69
+ byEndpoint = new Map<string, number>();
70
+ rejectedConsumeEndpoints.set(owner, byEndpoint);
71
+ }
72
+ byEndpoint.set(endpoint, Date.now());
73
+ };
74
+
50
75
  const hasConsumeEndpoint = (
51
76
  consumeSockets: ConsumeSocket[],
52
77
  endpoint: string
@@ -59,6 +84,7 @@ const hasConsumeEndpoint = (
59
84
  const socket = socketObj[ip];
60
85
  if (socket?.connected && socket.id) return true;
61
86
 
87
+ // A dead entry must not suppress the next room's connection attempt.
62
88
  socket?.removeAllListeners?.();
63
89
  socket?.disconnect?.();
64
90
  consumeSockets.splice(index, 1);
@@ -176,7 +202,9 @@ export const connectIps = async ({
176
202
  return [consume_sockets, roomRecvIPs];
177
203
  }
178
204
 
205
+ const rejectionOwner: object = parameters?.socket || consume_sockets;
179
206
  for (const ip of remIP) {
207
+ if (consumeEndpointRecentlyRejected(rejectionOwner, normalizeConsumeEndpoint(ip))) continue;
180
208
  const releaseReservation = await reserveConsumeEndpoint(
181
209
  consume_sockets,
182
210
  ip,
@@ -191,6 +219,20 @@ export const connectIps = async ({
191
219
  remote_sock = await connectSocket({ apiUserName, apiKey, apiToken, link: `https://${ip}.mediasfu.com` });
192
220
 
193
221
  if (remote_sock.id) {
222
+ let consumeRoomReady = false;
223
+ const pendingProducers: Array<{
224
+ producerId: string;
225
+ islevel: string;
226
+ isTranslation?: boolean;
227
+ translationMeta?: {
228
+ speakerId: string;
229
+ speakerName: string;
230
+ language: string;
231
+ originalProducerId?: string;
232
+ isSpeakerControlled?: boolean;
233
+ };
234
+ }> = [];
235
+
194
236
  // Check if the IP is in the roomRecvIPs, if not, add it
195
237
  if (!roomRecvIPs.includes(ip)) {
196
238
  roomRecvIPs.push(ip);
@@ -198,7 +240,21 @@ export const connectIps = async ({
198
240
  }
199
241
 
200
242
  // Handle new pipe producer event
201
- remote_sock.on("new-pipe-producer", async ({ producerId, islevel, isTranslation, translationMeta }: { producerId: string; islevel: string; isTranslation?: boolean; translationMeta?: { speakerId: string; speakerName: string; language: string; originalProducerId?: string; isSpeakerControlled?: boolean } }) => {
243
+ const consumeProducer = async ({ producerId, islevel, isTranslation, translationMeta, mediaTag }: { producerId: string; islevel: string; isTranslation?: boolean; translationMeta?: { speakerId: string; speakerName: string; language: string; originalProducerId?: string; isSpeakerControlled?: boolean }; mediaTag?: string }) => {
244
+ // Screen-share audio is tagged by the server; remember it before the consumer resumes.
245
+ markScreenAudioProducer(producerId, mediaTag);
246
+ if (!consumeRoomReady) {
247
+ pendingProducers.push({ producerId, islevel, isTranslation, translationMeta });
248
+ return;
249
+ }
250
+ // A busy node with a connected backup: give the backup's announcement a moment to win (same producer id).
251
+ const preferDelay = isTranslation ? 0 : consumerBackupPreferDelay(
252
+ ip,
253
+ (parameters.getUpdatedAllParams?.() as any)?.consume_sockets as any
254
+ );
255
+ if (preferDelay > 0) {
256
+ await new Promise((resolve) => setTimeout(resolve, preferDelay));
257
+ }
202
258
  if (newProducerMethod) {
203
259
  await newProducerMethod({
204
260
  producerId,
@@ -209,7 +265,8 @@ export const connectIps = async ({
209
265
  translationMeta,
210
266
  });
211
267
  }
212
- });
268
+ };
269
+ remote_sock.on("new-pipe-producer", consumeProducer);
213
270
 
214
271
  // Handle producer closed event
215
272
  remote_sock.on("producer-closed", async ({ remoteProducerId }: { remoteProducerId: string }) => {
@@ -231,6 +288,18 @@ export const connectIps = async ({
231
288
  }
232
289
  }
233
290
 
291
+ // Events can arrive after the socket connects but before it has
292
+ // joined the consuming room. Emitting transport requests during that
293
+ // window receives no acknowledgement and poisons global de-duplication.
294
+ // The join's producer sweep covers current producers; flushing the
295
+ // queue closes the event/sweep gap, and signal-level de-duplication
296
+ // makes overlap harmless.
297
+ const queuedProducers = pendingProducers.splice(0);
298
+ consumeRoomReady = true;
299
+ for (const producer of queuedProducers) {
300
+ await consumeProducer(producer);
301
+ }
302
+
234
303
  // Add the remote socket to the consume_sockets array
235
304
  consume_sockets.push({ [ip]: remote_sock });
236
305
  updateConsume_sockets(consume_sockets);
@@ -256,8 +325,12 @@ export const connectIps = async ({
256
325
  });
257
326
  }
258
327
  } catch (error) {
259
- // Handle the error
260
- console.log("connectIps error", error);
328
+ if (isMediaSocketConnectError(error) && !error.retryable) {
329
+ rememberConsumeEndpointRejection(rejectionOwner, normalizeConsumeEndpoint(ip));
330
+ console.warn(`connectIps: ${ip} refused this room (${error.reason}); not retrying for ${CONSUME_REJECTION_COOLDOWN_MS / 1000} s.`, error.message);
331
+ } else {
332
+ console.log("connectIps error", error);
333
+ }
261
334
  } finally {
262
335
  if (!connected && remote_sock) {
263
336
  remote_sock.removeAllListeners?.();
@@ -0,0 +1,184 @@
1
+ import type { Producer, Transport } from "mediasoup-client/types";
2
+ import { Socket } from "socket.io-client";
3
+ import {
4
+ SCREEN_AUDIO_CODEC_OPTIONS,
5
+ SCREEN_AUDIO_MEDIA_TAG,
6
+ recallScreenAudioProducer,
7
+ rememberScreenAudioProducer,
8
+ shouldShareScreenAudio,
9
+ } from "./screenAudio";
10
+
11
+ export interface ScreenAudioParameters {
12
+ socket: Socket;
13
+ producerTransport: Transport | null;
14
+ screenProducer?: Producer | null;
15
+ screenAudioProducer?: Producer | null;
16
+ supportsScreenAudio?: boolean;
17
+ screenAudioEnabled?: boolean;
18
+ updateScreenAudioProducer?: (producer: Producer | null) => void;
19
+ getUpdatedAllParams: () => ScreenAudioParameters;
20
+ [key: string]: any;
21
+ }
22
+
23
+ export interface ConnectSendTransportScreenAudioOptions {
24
+ stream: MediaStream;
25
+ parameters: ScreenAudioParameters;
26
+ /** Wait before the single retry when the server has not registered the screen share yet. */
27
+ retryDelayMs?: number;
28
+ }
29
+
30
+ export type ConnectSendTransportScreenAudioType = (
31
+ options: ConnectSendTransportScreenAudioOptions
32
+ ) => Promise<Producer | null>;
33
+
34
+ export interface DisconnectSendTransportScreenAudioOptions {
35
+ parameters: ScreenAudioParameters;
36
+ /** Also tell the server (the screen share continues). Not needed when the whole screen share stops. */
37
+ notifyServer?: boolean;
38
+ }
39
+
40
+ export type DisconnectSendTransportScreenAudioType = (
41
+ options: DisconnectSendTransportScreenAudioOptions
42
+ ) => Promise<void>;
43
+
44
+ const currentScreenAudioProducer = (parameters: ScreenAudioParameters): Producer | null =>
45
+ parameters.screenAudioProducer || recallScreenAudioProducer(parameters.socket);
46
+
47
+ const storeScreenAudioProducer = (parameters: ScreenAudioParameters, producer: Producer | null) => {
48
+ rememberScreenAudioProducer(parameters.socket, producer);
49
+ parameters.updateScreenAudioProducer?.(producer);
50
+ };
51
+
52
+ /**
53
+ * Closes the screen-audio producer, if any. Safe to call when none exists.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * await disconnectSendTransportScreenAudio({ parameters, notifyServer: true });
58
+ * ```
59
+ */
60
+ export const disconnectSendTransportScreenAudio: DisconnectSendTransportScreenAudioType = async ({
61
+ parameters,
62
+ notifyServer = false,
63
+ }) => {
64
+ const params = parameters.getUpdatedAllParams ? parameters.getUpdatedAllParams() : parameters;
65
+ const producer = currentScreenAudioProducer(params);
66
+ if (!producer) return;
67
+ try {
68
+ if (!producer.closed) producer.close();
69
+ } catch {
70
+ // already closed
71
+ }
72
+ storeScreenAudioProducer(params, null);
73
+ if (notifyServer) {
74
+ try {
75
+ params.socket?.emit("closeScreenAudioProducer");
76
+ } catch {
77
+ // the server also closes it with the screen share
78
+ }
79
+ }
80
+ };
81
+
82
+ export interface ToggleScreenAudioOptions {
83
+ parameters: ScreenAudioParameters;
84
+ /** Force a state; omitted = toggle. */
85
+ paused?: boolean;
86
+ }
87
+
88
+ /**
89
+ * Mutes or unmutes the sharer's screen audio without stopping the screen share (listeners hear silence while muted).
90
+ * Returns the new muted state, or null when no screen audio is being shared.
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * const muted = toggleScreenAudio({ parameters });
95
+ * ```
96
+ */
97
+ export const toggleScreenAudio = ({ parameters, paused }: ToggleScreenAudioOptions): boolean | null => {
98
+ const params = parameters.getUpdatedAllParams ? parameters.getUpdatedAllParams() : parameters;
99
+ const producer = currentScreenAudioProducer(params);
100
+ if (!producer || producer.closed) return null;
101
+ const next = paused ?? !producer.paused;
102
+ if (next && !producer.paused) producer.pause();
103
+ if (!next && producer.paused) producer.resume();
104
+ params.updateScreenAudioProducer?.(producer);
105
+ return producer.paused;
106
+ };
107
+
108
+ const produceOnce = async (transport: Transport, track: MediaStreamTrack): Promise<Producer | null> => {
109
+ const producer = await transport.produce({
110
+ track,
111
+ codecOptions: SCREEN_AUDIO_CODEC_OPTIONS,
112
+ appData: { mediaTag: SCREEN_AUDIO_MEDIA_TAG },
113
+ // The track belongs to the screen share stream, which stops it with the share.
114
+ stopTracks: false,
115
+ });
116
+ // A rejected produce resolves without a server id: drop the local sender.
117
+ if (!producer?.id) {
118
+ try { producer?.close(); } catch { /* nothing to close */ }
119
+ return null;
120
+ }
121
+ return producer;
122
+ };
123
+
124
+ /**
125
+ * Publishes the audio track of a screen-share stream as a "screen-audio" producer on the existing send transport.
126
+ * Call it after the screen video producer exists. It returns null (and the screen share continues without audio)
127
+ * when the stream has no audio, the server does not support screen audio, or the app turned it off.
128
+ *
129
+ * The producer mirrors the screen producer: it pauses, resumes and closes with it, and it closes alone (telling the
130
+ * server) when its own track ends.
131
+ *
132
+ * @example
133
+ * ```typescript
134
+ * const producer = await connectSendTransportScreenAudio({ stream: screenStream, parameters });
135
+ * ```
136
+ */
137
+ export const connectSendTransportScreenAudio: ConnectSendTransportScreenAudioType = async ({
138
+ stream,
139
+ parameters,
140
+ retryDelayMs = 600,
141
+ }) => {
142
+ const params = parameters.getUpdatedAllParams ? parameters.getUpdatedAllParams() : parameters;
143
+ const track = stream?.getAudioTracks?.()[0];
144
+ if (!track || track.readyState === "ended") return null;
145
+ if (!shouldShareScreenAudio(params)) return null;
146
+ const transport = params.producerTransport;
147
+ if (!transport || transport.closed) return null;
148
+
149
+ const existing = currentScreenAudioProducer(params);
150
+ if (existing && !existing.closed) return existing;
151
+
152
+ try {
153
+ let producer = await produceOnce(transport, track);
154
+ // The track may have ended while the first attempt was in flight.
155
+ const trackEnded = () => (track.readyState as MediaStreamTrackState) === "ended";
156
+ if (!producer && retryDelayMs > 0 && !trackEnded()) {
157
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
158
+ producer = await produceOnce(transport, track);
159
+ }
160
+ if (!producer) return null;
161
+
162
+ storeScreenAudioProducer(params, producer);
163
+
164
+ const screenProducer = params.screenProducer;
165
+ if (screenProducer?.paused) producer.pause();
166
+ screenProducer?.observer?.on("pause", () => {
167
+ if (!producer!.closed) producer!.pause();
168
+ });
169
+ screenProducer?.observer?.on("resume", () => {
170
+ if (!producer!.closed) producer!.resume();
171
+ });
172
+ screenProducer?.observer?.on("close", () => {
173
+ void disconnectSendTransportScreenAudio({ parameters: params });
174
+ });
175
+
176
+ track.addEventListener("ended", () => {
177
+ void disconnectSendTransportScreenAudio({ parameters: params, notifyServer: true });
178
+ });
179
+ return producer;
180
+ } catch (error) {
181
+ console.log("connectSendTransportScreenAudio error", error);
182
+ return null;
183
+ }
184
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Consumer backup preference (server feature consumer-backup-v1).
3
+ *
4
+ * When a consuming node gets busy, the server attaches a backup node and tells every listener
5
+ * `alt_domains: { <busy node>: <backup node> }`. Listeners stay on the busy node for what they already receive, but a
6
+ * stream announced after that is taken from the backup: when the busy node announces it and the backup is connected,
7
+ * the client waits briefly for the backup's own announcement (the backup gets each new stream a moment later) and only
8
+ * falls back to the busy node if it does not come. Consumption is de-duplicated by producer id, so whichever node is
9
+ * used first wins and the other announcement is ignored.
10
+ *
11
+ * The request is never sent to the backup for a stream the backup has not announced: a consume the backup cannot serve
12
+ * fails without an error and the de-duplication would then skip the busy node's copy (staging test, Oct 9 2026).
13
+ */
14
+ const backupByParent = new Map<string, string>();
15
+
16
+ /** How long a busy node's announcement waits for the backup's (ms). */
17
+ export const CONSUMER_BACKUP_PREFER_DELAY_MS = 1500;
18
+
19
+ /** Records the server's `{ busy node: backup node }` map (from `updateConsumingDomains` / `getDomains`). */
20
+ export const rememberConsumerBackups = (altDomains?: Record<string, string> | null): void => {
21
+ for (const [parent, backup] of Object.entries(altDomains || {})) {
22
+ if (parent && backup && parent !== backup) backupByParent.set(parent, backup);
23
+ }
24
+ };
25
+
26
+ export const forgetConsumerBackups = (): void => backupByParent.clear();
27
+
28
+ type SocketEntry = Record<string, { connected?: boolean } | undefined>;
29
+
30
+ /** The connected backup to prefer over `ip`, or null. */
31
+ export const preferredConsumerBackup = (ip: string, consumeSockets?: SocketEntry[] | null): string | null => {
32
+ const backup = backupByParent.get(ip);
33
+ if (!backup) return null;
34
+ const connected = (consumeSockets || []).some((entry) => {
35
+ const socket = entry?.[backup];
36
+ return Boolean(socket) && socket!.connected !== false;
37
+ });
38
+ return connected ? backup : null;
39
+ };
40
+
41
+ /** Delay (ms) before consuming a stream announced by `ip`: non-zero only when a connected backup should win. */
42
+ export const consumerBackupPreferDelay = (ip: string, consumeSockets?: SocketEntry[] | null): number =>
43
+ preferredConsumerBackup(ip, consumeSockets) ? CONSUMER_BACKUP_PREFER_DELAY_MS : 0;
@@ -1,5 +1,6 @@
1
1
  import type { Producer } from 'mediasoup-client/types';
2
2
  import { Socket } from "socket.io-client";
3
+ import { disconnectSendTransportScreenAudio } from "./connectSendTransportScreenAudio";
3
4
 
4
5
  export interface DisconnectSendTransportScreenParameters {
5
6
  screenProducer: Producer | null;
@@ -94,6 +95,13 @@ export const disconnectSendTransportScreen = async ({ parameters }: DisconnectSe
94
95
  let { getUpdatedAllParams } = parameters;
95
96
  parameters = getUpdatedAllParams()
96
97
 
98
+ try {
99
+ // Screen audio stops with the screen share (the server closes its side on closeScreenProducer).
100
+ await disconnectSendTransportScreenAudio({ parameters: parameters as any });
101
+ } catch {
102
+ // nothing to close
103
+ }
104
+
97
105
  try {
98
106
  // Destructure parameters
99
107
  let {
@@ -1,5 +1,6 @@
1
1
  import { Socket } from "socket.io-client";
2
2
  import { SignalNewConsumerTransportParameters, SignalNewConsumerTransportType } from '../types/types';
3
+ import { markScreenAudioProducer } from './screenAudio';
3
4
 
4
5
  interface TranslationMeta {
5
6
  speakerId: string;
@@ -12,6 +13,8 @@ interface TranslationMeta {
12
13
  interface ProducerInfo {
13
14
  id: string;
14
15
  translationMeta?: TranslationMeta | null;
16
+ /** "screen-audio" for audio shared with a screen share. */
17
+ mediaTag?: string;
15
18
  }
16
19
 
17
20
  export interface GetPipedProducersAltParameters extends Omit<SignalNewConsumerTransportParameters, 'getUpdatedAllParams'> {
@@ -151,6 +154,7 @@ export const getPipedProducersAlt = async ({
151
154
  } else {
152
155
  producerId = producer.id;
153
156
  translationMeta = producer.translationMeta || null;
157
+ markScreenAudioProducer(producerId, producer.mediaTag);
154
158
  }
155
159
 
156
160
  if (translationMeta) {
@@ -13,6 +13,9 @@ export * from './connectRecvTransport';
13
13
  export * from './connectSendTransport';
14
14
  export * from './connectSendTransportAudio';
15
15
  export * from './connectSendTransportScreen';
16
+ export * from './connectSendTransportScreenAudio';
17
+ export * from './screenAudio';
18
+ export * from './consumerBackupPreference';
16
19
  export * from './connectSendTransportVideo';
17
20
  export * from './consumerResume';
18
21
  export * from './controlMedia';