mediasfu-shared 1.2.7 → 1.3.1

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 (114) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/ProducerClient/producerClientEmits/updateRoomParametersClient.d.ts +2 -0
  3. package/dist/ProducerClient/producerClientEmits/updateRoomParametersClient.d.ts.map +1 -1
  4. package/dist/audioProcessing-Ca5wm1nV.js +150 -0
  5. package/dist/audioProcessing-Ca5wm1nV.js.map +1 -0
  6. package/dist/audioProcessing-VCbu70Jt.cjs +149 -0
  7. package/dist/audioProcessing-VCbu70Jt.cjs.map +1 -0
  8. package/dist/consumers/closeAndResize.d.ts +3 -0
  9. package/dist/consumers/closeAndResize.d.ts.map +1 -1
  10. package/dist/consumers/connectIps.d.ts +6 -0
  11. package/dist/consumers/connectIps.d.ts.map +1 -1
  12. package/dist/consumers/connectSendTransportAudio.d.ts.map +1 -1
  13. package/dist/consumers/connectSendTransportScreen.d.ts.map +1 -1
  14. package/dist/consumers/connectSendTransportScreenAudio.d.ts +65 -0
  15. package/dist/consumers/connectSendTransportScreenAudio.d.ts.map +1 -0
  16. package/dist/consumers/connectSendTransportVideo.d.ts.map +1 -1
  17. package/dist/consumers/consumerBackupPreference.d.ts +14 -0
  18. package/dist/consumers/consumerBackupPreference.d.ts.map +1 -0
  19. package/dist/consumers/createSendTransport.d.ts +4 -48
  20. package/dist/consumers/createSendTransport.d.ts.map +1 -1
  21. package/dist/consumers/disconnectSendTransportScreen.d.ts.map +1 -1
  22. package/dist/consumers/getPipedProducersAlt.d.ts.map +1 -1
  23. package/dist/consumers/index.cjs +23 -1
  24. package/dist/consumers/index.cjs.map +1 -1
  25. package/dist/consumers/index.d.ts +3 -0
  26. package/dist/consumers/index.d.ts.map +1 -1
  27. package/dist/consumers/index.js +61 -39
  28. package/dist/consumers/index.js.map +1 -1
  29. package/dist/consumers/screenAudio.d.ts +64 -0
  30. package/dist/consumers/screenAudio.d.ts.map +1 -0
  31. package/dist/consumers/sendTransportGuard.d.ts +30 -0
  32. package/dist/consumers/sendTransportGuard.d.ts.map +1 -0
  33. package/dist/consumers/socketReceiveMethods/producerClosed.d.ts.map +1 -1
  34. package/dist/consumers/startShareScreen.d.ts +14 -0
  35. package/dist/consumers/startShareScreen.d.ts.map +1 -1
  36. package/dist/consumers/streamSuccessScreen.d.ts.map +1 -1
  37. package/dist/{getParticipantMedia-DTZg8n5U.js → getParticipantMedia-CZbsFBth.js} +4 -4
  38. package/dist/{getParticipantMedia-DTZg8n5U.js.map → getParticipantMedia-CZbsFBth.js.map} +1 -1
  39. package/dist/{getParticipantMedia-c2bbdT5f.cjs → getParticipantMedia-Dli4dOLp.cjs} +4 -4
  40. package/dist/{getParticipantMedia-c2bbdT5f.cjs.map → getParticipantMedia-Dli4dOLp.cjs.map} +1 -1
  41. package/dist/index.cjs +37 -6
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1525 -1494
  46. package/dist/index.native.cjs +37 -6
  47. package/dist/index.native.cjs.map +1 -1
  48. package/dist/index.native.d.ts +4 -0
  49. package/dist/index.native.d.ts.map +1 -1
  50. package/dist/index.native.js +107 -76
  51. package/dist/{joinLocalRoom-1dRd2PCN.js → joinLocalRoom-BWoS-8wt.js} +6 -4
  52. package/dist/joinLocalRoom-BWoS-8wt.js.map +1 -0
  53. package/dist/{joinLocalRoom-DwY-3ZsH.cjs → joinLocalRoom-DQvud99t.cjs} +6 -4
  54. package/dist/joinLocalRoom-DQvud99t.cjs.map +1 -0
  55. package/dist/{joinRoomOnMediaSFU-BnAWLciF.js → joinRoomOnMediaSFU-B6tTA0Mx.js} +17 -13
  56. package/dist/joinRoomOnMediaSFU-B6tTA0Mx.js.map +1 -0
  57. package/dist/{joinRoomOnMediaSFU-D2C7q1GK.cjs → joinRoomOnMediaSFU-CxwD9fBz.cjs} +17 -13
  58. package/dist/joinRoomOnMediaSFU-CxwD9fBz.cjs.map +1 -0
  59. package/dist/methods/index.cjs +11 -8
  60. package/dist/methods/index.cjs.map +1 -1
  61. package/dist/methods/index.js +13 -9
  62. package/dist/methods/index.js.map +1 -1
  63. package/dist/methods/socketReceive/getDomains.d.ts.map +1 -1
  64. package/dist/methods/utils/checkLimitsAndMakeRequest.d.ts.map +1 -1
  65. package/dist/methods/welcome/handleWelcomeRequest.d.ts.map +1 -1
  66. package/dist/sockets/SocketManager.d.ts +7 -0
  67. package/dist/sockets/SocketManager.d.ts.map +1 -1
  68. package/dist/sockets/mediaSocketErrors.d.ts +62 -0
  69. package/dist/sockets/mediaSocketErrors.d.ts.map +1 -0
  70. package/dist/types/index.cjs +4 -3
  71. package/dist/types/index.cjs.map +1 -1
  72. package/dist/types/index.js +39 -38
  73. package/dist/types/shared-base-types.d.ts +4 -0
  74. package/dist/types/shared-base-types.d.ts.map +1 -1
  75. package/dist/{updateParticipantAudioDecibels-BJ2PxJJD.cjs → updateParticipantAudioDecibels-CbCP1PDs.cjs} +467 -89
  76. package/dist/updateParticipantAudioDecibels-CbCP1PDs.cjs.map +1 -0
  77. package/dist/{updateParticipantAudioDecibels-DiJ_LFfH.js → updateParticipantAudioDecibels-xKKq7Zp5.js} +505 -127
  78. package/dist/updateParticipantAudioDecibels-xKKq7Zp5.js.map +1 -0
  79. package/package.json +3 -2
  80. package/src/ProducerClient/producerClientEmits/updateRoomParametersClient.ts +4 -0
  81. package/src/consumers/closeAndResize.ts +14 -0
  82. package/src/consumers/connectIps.ts +81 -8
  83. package/src/consumers/connectSendTransportAudio.ts +7 -4
  84. package/src/consumers/connectSendTransportScreen.ts +6 -4
  85. package/src/consumers/connectSendTransportScreenAudio.ts +184 -0
  86. package/src/consumers/connectSendTransportVideo.ts +4 -2
  87. package/src/consumers/consumerBackupPreference.ts +43 -0
  88. package/src/consumers/createSendTransport.ts +62 -4
  89. package/src/consumers/disconnectSendTransportScreen.ts +8 -0
  90. package/src/consumers/getPipedProducersAlt.ts +4 -0
  91. package/src/consumers/index.ts +3 -0
  92. package/src/consumers/screenAudio.ts +132 -0
  93. package/src/consumers/sendTransportGuard.ts +65 -0
  94. package/src/consumers/socketReceiveMethods/producerClosed.ts +3 -0
  95. package/src/consumers/startShareScreen.ts +44 -8
  96. package/src/consumers/streamSuccessScreen.ts +11 -0
  97. package/src/index.native.ts +4 -0
  98. package/src/index.ts +4 -0
  99. package/src/methods/socketReceive/getDomains.ts +4 -0
  100. package/src/methods/utils/checkLimitsAndMakeRequest.ts +24 -11
  101. package/src/methods/welcome/handleWelcomeRequest.ts +15 -6
  102. package/src/sockets/SocketManager.ts +107 -13
  103. package/src/sockets/mediaSocketErrors.ts +156 -0
  104. package/src/types/shared-base-types.ts +4 -0
  105. package/dist/audioProcessing-0z5KlcmO.js +0 -35
  106. package/dist/audioProcessing-0z5KlcmO.js.map +0 -1
  107. package/dist/audioProcessing-BpbrdCk-.cjs +0 -34
  108. package/dist/audioProcessing-BpbrdCk-.cjs.map +0 -1
  109. package/dist/joinLocalRoom-1dRd2PCN.js.map +0 -1
  110. package/dist/joinLocalRoom-DwY-3ZsH.cjs.map +0 -1
  111. package/dist/joinRoomOnMediaSFU-BnAWLciF.js.map +0 -1
  112. package/dist/joinRoomOnMediaSFU-D2C7q1GK.cjs.map +0 -1
  113. package/dist/updateParticipantAudioDecibels-BJ2PxJJD.cjs.map +0 -1
  114. 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.7",
3
+ "version": "1.3.1",
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",
@@ -117,6 +117,8 @@ export interface UpdateRoomParametersClientParameters {
117
117
  updateRecordingPreferredOrientation: (orientation: string) => void;
118
118
  updateRecordingSupportForOtherOrientation: (support: boolean) => void;
119
119
  updateRecordingMultiFormatsSupport: (support: boolean) => void;
120
+ /** Records whether the media server accepts screen-share audio (join response `supportsScreenAudio`). */
121
+ updateSupportsScreenAudio?: (supported: boolean) => void;
120
122
  updateRecordingVideoOptions: (options: string) => void;
121
123
  updateRecordingAudioOptions: (options: string) => void;
122
124
  }
@@ -184,6 +186,7 @@ export const updateRoomParametersClient = ({ parameters, simulcastOff = paramete
184
186
  updateRecordingPreferredOrientation,
185
187
  updateRecordingSupportForOtherOrientation,
186
188
  updateRecordingMultiFormatsSupport,
189
+ updateSupportsScreenAudio,
187
190
  updateRecordingVideoOptions,
188
191
  updateRecordingAudioOptions,
189
192
  updateMainHeightWidth,
@@ -221,6 +224,7 @@ export const updateRoomParametersClient = ({ parameters, simulcastOff = paramete
221
224
  updateRecordingPreferredOrientation(data.recordingParams.recordingPreferredOrientation);
222
225
  updateRecordingSupportForOtherOrientation(data.recordingParams.recordingSupportForOtherOrientation);
223
226
  updateRecordingMultiFormatsSupport(data.recordingParams.recordingMultiFormatsSupport);
227
+ updateSupportsScreenAudio?.(data.supportsScreenAudio === true);
224
228
 
225
229
  updateItemPageLimit(data.meetingRoomParams.itemPageLimit);
226
230
  updateEventType(data.meetingRoomParams.type);
@@ -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?.();
@@ -1,4 +1,5 @@
1
1
  import type { Transport, Producer, ProducerOptions } from 'mediasoup-client/types';
2
+ import { produceOnce } from "./sendTransportGuard";
2
3
 
3
4
  export interface ConnectSendTransportAudioParameters {
4
5
  audioProducer: Producer | null;
@@ -39,7 +40,7 @@ const connectLocalSendTransportAudio = async ({
39
40
  } = parameters;
40
41
 
41
42
  if (localProducerTransport) {
42
- localAudioProducer = await localProducerTransport.produce(audioParams);
43
+ localAudioProducer = (await produceOnce(localProducerTransport, "audio", () => localProducerTransport!.produce(audioParams))).producer;
43
44
 
44
45
  updateLocalAudioProducer?.(localAudioProducer);
45
46
  updateLocalProducerTransport?.(localProducerTransport);
@@ -127,10 +128,12 @@ export const connectSendTransportAudio: ConnectSendTransportAudioType = async ({
127
128
 
128
129
  // Attempt to connect the primary send transport
129
130
  if (targetOption === "all" || targetOption === "remote") {
130
- audioProducer = await producerTransport!.produce(audioParams);
131
+ // A concurrent call for the same transport shares the producer instead of creating a second one.
132
+ const produced = await produceOnce(producerTransport!, "audio", () => producerTransport!.produce(audioParams));
133
+ audioProducer = produced.producer;
131
134
 
132
- // Update the audio level
133
- updateMicLevel(audioProducer, parameters.updateAudioLevel);
135
+ // Update the audio level (once per producer)
136
+ if (!produced.joined) updateMicLevel(audioProducer, parameters.updateAudioLevel);
134
137
 
135
138
  // Update state with the new producer and transport
136
139
  updateAudioProducer(audioProducer);
@@ -1,4 +1,5 @@
1
1
  import type { Transport, Producer, Device, ProducerOptions } from 'mediasoup-client/types';
2
+ import { produceOnce } from "./sendTransportGuard";
2
3
 
3
4
  export interface ConnectSendTransportScreenParameters {
4
5
  screenProducer: Producer | null;
@@ -48,11 +49,11 @@ const connectLocalSendTransportScreen = async ({
48
49
 
49
50
  // Produce local screen share data
50
51
  if (localProducerTransport) {
51
- localScreenProducer = await localProducerTransport.produce({
52
+ localScreenProducer = (await produceOnce(localProducerTransport, "screen", () => localProducerTransport!.produce({
52
53
  track: stream.getVideoTracks()[0],
53
54
  codec,
54
55
  appData: { mediaTag: "screen-video" },
55
- });
56
+ }))).producer;
56
57
 
57
58
  // Update the local producer and transport objects
58
59
  updateLocalScreenProducer?.(localScreenProducer);
@@ -144,12 +145,13 @@ export const connectSendTransportScreen: ConnectSendTransportScreenType = async
144
145
 
145
146
  // Produce screen share data using the producer transport
146
147
  if (targetOption === "remote" || targetOption === "all") {
147
- screenProducer = await producerTransport!.produce({
148
+ // A concurrent call for the same transport shares the producer instead of creating a second one.
149
+ screenProducer = (await produceOnce(producerTransport!, "screen", () => producerTransport!.produce({
148
150
  track: stream.getVideoTracks()[0],
149
151
  ...params,
150
152
  codec,
151
153
  appData: { mediaTag: "screen-video" },
152
- });
154
+ }))).producer;
153
155
 
154
156
  // Update the screen producer and producer transport objects
155
157
  updateScreenProducer(screenProducer);
@@ -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
+ };
@@ -1,4 +1,5 @@
1
1
  import type { Device, Producer, ProducerOptions, Transport } from 'mediasoup-client/types';
2
+ import { produceOnce } from "./sendTransportGuard";
2
3
 
3
4
  export interface ConnectSendTransportVideoParameters {
4
5
  videoProducer: Producer | null;
@@ -39,7 +40,7 @@ const connectLocalSendTransportVideo = async ({
39
40
 
40
41
  // Produce local video data if transport exists
41
42
  if (localProducerTransport) {
42
- localVideoProducer = await localProducerTransport.produce(videoParams);
43
+ localVideoProducer = (await produceOnce(localProducerTransport, "video", () => localProducerTransport!.produce(videoParams))).producer;
43
44
 
44
45
  // Update local producer and transport
45
46
  updateLocalVideoProducer?.(localVideoProducer);
@@ -121,7 +122,8 @@ export const connectSendTransportVideo: ConnectSendTransportVideoType = async ({
121
122
 
122
123
  // Produce video data using the primary transport
123
124
  if (targetOption === "all" || targetOption === "remote"){
124
- videoProducer = await producerTransport!.produce(videoParams);
125
+ // A concurrent call for the same transport shares the producer instead of creating a second one.
126
+ videoProducer = (await produceOnce(producerTransport!, "video", () => producerTransport!.produce(videoParams))).producer;
125
127
 
126
128
  // Update main window state based on the video connection level
127
129
  if (islevel === "2") {
@@ -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;