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
@@ -0,0 +1,132 @@
1
+ import type { Producer } from "mediasoup-client/types";
2
+ import { Participant } from "../types/types";
3
+
4
+ /**
5
+ * Screen audio: the audio of a shared tab, window or screen, published as its own producer tagged
6
+ * `appData.mediaTag === "screen-audio"`. It belongs to the screen share (starts, pauses, resumes and stops with it),
7
+ * never to the sharer's microphone, so it never marks the sharer as speaking.
8
+ *
9
+ * A client only publishes it when the media server says so in the join response (`supportsScreenAudio`), so new
10
+ * clients keep today's video-only screen share on older servers.
11
+ */
12
+ export const SCREEN_AUDIO_MEDIA_TAG = "screen-audio";
13
+
14
+ /** Opus settings for shared media (music, video soundtracks) rather than voice. */
15
+ export const SCREEN_AUDIO_CODEC_OPTIONS = {
16
+ opusStereo: true,
17
+ opusDtx: false,
18
+ opusFec: true,
19
+ opusMaxAverageBitrate: 128000,
20
+ };
21
+
22
+ // Producer ids are globally unique, so one registry serves every room instance on the page.
23
+ const screenAudioProducerIds = new Set<string>();
24
+
25
+ /** Records a producer announced by the server as screen audio (`mediaTag: "screen-audio"`). */
26
+ export const markScreenAudioProducer = (producerId?: string | null, mediaTag?: string | null): void => {
27
+ if (producerId && mediaTag === SCREEN_AUDIO_MEDIA_TAG) screenAudioProducerIds.add(producerId);
28
+ };
29
+
30
+ export const forgetScreenAudioProducer = (producerId?: string | null): void => {
31
+ if (producerId) screenAudioProducerIds.delete(producerId);
32
+ };
33
+
34
+ export interface IsScreenAudioProducerOptions {
35
+ producerId: string;
36
+ participants?: Participant[];
37
+ appData?: Record<string, unknown> | null;
38
+ }
39
+
40
+ /** True when the producer is screen audio, by server tag, consumer appData or the sharer's `ScreenAudioID`. */
41
+ export const isScreenAudioProducer = ({ producerId, participants = [], appData }: IsScreenAudioProducerOptions): boolean => {
42
+ if (!producerId) return false;
43
+ if (screenAudioProducerIds.has(producerId)) return true;
44
+ if (appData?.mediaTag === SCREEN_AUDIO_MEDIA_TAG) return true;
45
+ return participants.some((participant) => participant?.ScreenAudioID === producerId);
46
+ };
47
+
48
+ /**
49
+ * How a screen share treats audio: `ask` (default) shows a choice before the browser's picker, `always` requests audio
50
+ * without asking, `never` shares video only.
51
+ */
52
+ export type ScreenAudioMode = "ask" | "always" | "never";
53
+
54
+ export interface ScreenAudioSettings {
55
+ supportsScreenAudio?: boolean;
56
+ screenAudioEnabled?: boolean;
57
+ screenAudioMode?: ScreenAudioMode;
58
+ }
59
+
60
+ /** Screen audio is requested only when the server supports it and the app has not turned it off. */
61
+ export const shouldShareScreenAudio = ({ supportsScreenAudio, screenAudioEnabled, screenAudioMode }: ScreenAudioSettings): boolean =>
62
+ supportsScreenAudio === true && screenAudioEnabled !== false && screenAudioMode !== "never";
63
+
64
+ /**
65
+ * Whether this browser can capture the audio of a shared tab or screen. Chromium desktop browsers (Chrome, Edge,
66
+ * Opera, Brave) can; Firefox, Safari and mobile browsers return video only.
67
+ */
68
+ export const canCaptureDisplayAudio = (nav: Partial<Navigator> | undefined = typeof navigator !== "undefined" ? navigator : undefined): boolean => {
69
+ if (!nav) return false;
70
+ const ua = String(nav.userAgent || "");
71
+ if (/Mobi|Android|iPhone|iPad|iPod/i.test(ua)) return false;
72
+ const brands: string[] = ((nav as any).userAgentData?.brands || []).map((entry: { brand?: string }) => String(entry?.brand || ""));
73
+ if (brands.some((brand) => /Chromium|Google Chrome|Microsoft Edge|Opera|Brave/i.test(brand))) return true;
74
+ return /(Chrome|Chromium|Edg)\//.test(ua) && !/Firefox|FxiOS|OPiOS/.test(ua);
75
+ };
76
+
77
+ /** The user's answer to "share audio too?": `audio`, `video` (screen only) or `null` (cancelled). */
78
+ export type ScreenAudioChoice = "audio" | "video" | null;
79
+
80
+ /** Mutes or unmutes the sharer's screen audio without stopping the screen share. Returns the new paused state. */
81
+ export const setScreenAudioPaused = (producer: Producer | null | undefined, paused: boolean): boolean => {
82
+ if (!producer || producer.closed) return false;
83
+ if (paused && !producer.paused) producer.pause();
84
+ if (!paused && producer.paused) producer.resume();
85
+ return producer.paused;
86
+ };
87
+
88
+ export interface DisplayMediaOptionsInput {
89
+ targetWidth: number;
90
+ targetHeight: number;
91
+ withAudio: boolean;
92
+ }
93
+
94
+ /**
95
+ * Options for `getDisplayMedia`. With audio, the browser's picker shows its own "share audio" choice, which is the
96
+ * user's consent; browsers that cannot capture display audio (Firefox, Safari) return a video-only stream.
97
+ */
98
+ export const buildDisplayMediaOptions = ({ targetWidth, targetHeight, withAudio }: DisplayMediaOptionsInput) => {
99
+ const options: Record<string, unknown> = {
100
+ video: { width: targetWidth, height: targetHeight, frameRate: 30 },
101
+ audio: withAudio
102
+ ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false }
103
+ : false,
104
+ };
105
+ if (withAudio) {
106
+ // Chromium hints; other browsers ignore unknown members.
107
+ options.systemAudio = "include";
108
+ options.suppressLocalAudioPlayback = false;
109
+ }
110
+ return options as DisplayMediaStreamOptions;
111
+ };
112
+
113
+ // Fallback holder for apps whose parameters have no `screenAudioProducer` state.
114
+ const producersBySocket = new WeakMap<object, Producer>();
115
+
116
+ export const rememberScreenAudioProducer = (socket: object | null | undefined, producer: Producer | null): void => {
117
+ if (!socket) return;
118
+ if (producer) producersBySocket.set(socket, producer);
119
+ else producersBySocket.delete(socket);
120
+ };
121
+
122
+ export const recallScreenAudioProducer = (socket: object | null | undefined): Producer | null =>
123
+ (socket && producersBySocket.get(socket)) || null;
124
+
125
+ /** Sets the listening volume (0..1) of every screen-audio player on the page. */
126
+ export const setScreenAudioVolume = (volume: number, root: ParentNode | null = typeof document !== "undefined" ? document : null): void => {
127
+ if (!root) return;
128
+ const value = Math.min(1, Math.max(0, Number.isFinite(volume) ? volume : 1));
129
+ root.querySelectorAll<HTMLAudioElement>('audio[data-screen-audio="true"]').forEach((element) => {
130
+ element.volume = value;
131
+ });
132
+ };
@@ -1,4 +1,5 @@
1
1
  import { Transport, CloseAndResizeParameters, CloseAndResizeType } from '../../types/types';
2
+ import { forgetScreenAudioProducer } from '../screenAudio';
2
3
 
3
4
  export interface ProducerClosedParameters extends CloseAndResizeParameters {
4
5
  consumerTransports: Transport[];
@@ -63,6 +64,8 @@ export const producerClosed = async ({
63
64
  }: ProducerClosedOptions): Promise<void> => {
64
65
  let { consumerTransports, closeAndResize, screenId, updateConsumerTransports } = parameters;
65
66
 
67
+ forgetScreenAudioProducer(remoteProducerId);
68
+
66
69
  const activeTranslationProducerIds = parameters.activeTranslationProducerIds;
67
70
  const isTranslationProducer = activeTranslationProducerIds?.has?.(remoteProducerId);
68
71
 
@@ -1,5 +1,12 @@
1
1
 
2
2
  import { StreamSuccessScreenType, StreamSuccessScreenParameters, ShowAlert } from "../types/types";
3
+ import {
4
+ buildDisplayMediaOptions,
5
+ canCaptureDisplayAudio,
6
+ shouldShareScreenAudio,
7
+ ScreenAudioChoice,
8
+ ScreenAudioMode,
9
+ } from "./screenAudio";
3
10
  export interface StartShareScreenParameters extends StreamSuccessScreenParameters {
4
11
  shared: boolean;
5
12
  showAlert?: ShowAlert;
@@ -8,6 +15,19 @@ export interface StartShareScreenParameters extends StreamSuccessScreenParameter
8
15
  onWeb: boolean;
9
16
  targetWidth?: number;
10
17
  targetHeight?: number;
18
+ /** Set from the join response: the media server accepts screen audio. */
19
+ supportsScreenAudio?: boolean;
20
+ /** Ask the browser for the screen's audio too (default true; the browser's picker still lets the user decide). */
21
+ screenAudioEnabled?: boolean;
22
+ /** `ask` (default): offer "Screen + audio" / "Screen only" first; `always`: request audio; `never`: video only. */
23
+ screenAudioMode?: ScreenAudioMode;
24
+ /**
25
+ * The "share audio too?" dialog: resolve `audio`, `video` or `null` (cancel). Each UI SDK supplies its own. Without
26
+ * one, `ask` requests audio and leaves the choice to the browser picker's own "share audio" checkbox.
27
+ */
28
+ screenAudioPrompt?: () => Promise<ScreenAudioChoice>;
29
+ /** Override browser detection of display-audio capture (Chromium desktop by default). */
30
+ displayAudioCapable?: boolean;
11
31
 
12
32
  // mediasfu functions
13
33
  streamSuccessScreen: StreamSuccessScreenType;
@@ -71,6 +91,11 @@ export async function startShareScreen({ parameters }: StartShareScreenOptions):
71
91
  onWeb,
72
92
  targetWidth = 1280,
73
93
  targetHeight = 720,
94
+ supportsScreenAudio,
95
+ screenAudioEnabled,
96
+ screenAudioMode = "ask",
97
+ screenAudioPrompt,
98
+ displayAudioCapable,
74
99
 
75
100
  streamSuccessScreen,
76
101
  } = parameters;
@@ -86,16 +111,27 @@ export async function startShareScreen({ parameters }: StartShareScreenOptions):
86
111
  }
87
112
 
88
113
  if (mediaDevices && mediaDevices.getDisplayMedia) {
114
+ // Screen audio needs a server that accepts it and a browser that can capture it (Chromium desktop).
115
+ let withAudio =
116
+ shouldShareScreenAudio({ supportsScreenAudio, screenAudioEnabled, screenAudioMode }) &&
117
+ (displayAudioCapable ?? canCaptureDisplayAudio());
118
+ if (withAudio && screenAudioMode === "ask" && screenAudioPrompt) {
119
+ const choice = await screenAudioPrompt();
120
+ if (choice === null) {
121
+ updateShared(false);
122
+ return;
123
+ }
124
+ withAudio = choice === "audio";
125
+ }
89
126
  shared = true;
90
127
  await mediaDevices
91
- .getDisplayMedia({
92
- video: {
93
- width: targetWidth,
94
- height: targetHeight,
95
- frameRate: 30,
96
- },
97
- audio: false,
98
- })
128
+ .getDisplayMedia(
129
+ buildDisplayMediaOptions({
130
+ targetWidth,
131
+ targetHeight,
132
+ withAudio,
133
+ })
134
+ )
99
135
  .then(async (stream: MediaStream) => {
100
136
  await streamSuccessScreen({ stream, parameters });
101
137
  })
@@ -1,6 +1,7 @@
1
1
 
2
2
 
3
3
  import { Socket } from "socket.io-client";
4
+ import { connectSendTransportScreenAudio } from "./connectSendTransportScreenAudio";
4
5
  import {
5
6
  SleepType, CreateSendTransportType, ConnectSendTransportScreenType, DisconnectSendTransportScreenType, StopShareScreenType, ReorderStreamsType, PrepopulateUserMediaType, RePortType,
6
7
  ShowAlert, CreateSendTransportParameters, ConnectSendTransportScreenParameters, DisconnectSendTransportScreenParameters, StopShareScreenParameters, ReorderStreamsParameters, PrepopulateUserMediaParameters, RePortParameters,
@@ -179,6 +180,16 @@ export const streamSuccessScreen = async ({
179
180
 
180
181
  // Alert the socket that you are sharing the screen
181
182
  socket.emit("startScreenShare");
183
+
184
+ // Share the screen's audio too, when the browser captured it and the server supports it.
185
+ try {
186
+ await connectSendTransportScreenAudio({
187
+ stream: localStreamScreen,
188
+ parameters: getUpdatedAllParams() as any,
189
+ });
190
+ } catch (error) {
191
+ console.log("Screen audio not shared:", error);
192
+ }
182
193
  } catch (error: any) {
183
194
  showAlert?.({
184
195
  message: error.message,
@@ -17,6 +17,9 @@ export * from './consumers/connectSendTransport';
17
17
  export * from './consumers/connectSendTransportAudio';
18
18
  export * from './consumers/consumerResume';
19
19
  export * from './consumers/connectSendTransportScreen';
20
+ export * from './consumers/connectSendTransportScreenAudio';
21
+ export * from './consumers/screenAudio';
22
+ export * from './consumers/consumerBackupPreference';
20
23
  export * from './consumers/connectSendTransportVideo';
21
24
  export * from './consumers/controlMedia';
22
25
  export * from './consumers/createSendTransport';
@@ -217,6 +220,7 @@ export * from './producers/producerEmits/joinLocalRoom';
217
220
  export * from './producers/producerEmits/joinRoom';
218
221
 
219
222
  export * from './sockets/SocketManager';
223
+ export * from './sockets/mediaSocketErrors';
220
224
 
221
225
  export * from './methods/stream/clickVideo';
222
226
 
package/src/index.ts CHANGED
@@ -20,6 +20,9 @@ export * from './consumers/connectSendTransport';
20
20
  export * from './consumers/connectSendTransportAudio';
21
21
  export * from './consumers/consumerResume';
22
22
  export * from './consumers/connectSendTransportScreen';
23
+ export * from './consumers/connectSendTransportScreenAudio';
24
+ export * from './consumers/screenAudio';
25
+ export * from './consumers/consumerBackupPreference';
23
26
  export * from './consumers/connectSendTransportVideo';
24
27
  export * from './consumers/controlMedia';
25
28
  export * from './consumers/createSendTransport';
@@ -222,6 +225,7 @@ export * from './producers/producerEmits/joinLocalRoom';
222
225
  export * from './producers/producerEmits/joinRoom';
223
226
  // Socket exports
224
227
  export * from './sockets/SocketManager';
228
+ export * from './sockets/mediaSocketErrors';
225
229
  // Stream method exports (clickVideo)
226
230
  export * from './methods/stream/clickVideo';
227
231
 
@@ -3,6 +3,7 @@ import type {
3
3
  AltDomains,
4
4
  ConsumeSocket,
5
5
  } from '../../types/types';
6
+ import { rememberConsumerBackups } from "../../consumers/consumerBackupPreference";
6
7
 
7
8
  export interface ConnectIpsLikeOptions<
8
9
  TParameters = unknown,
@@ -88,6 +89,9 @@ export const getDomains = async <
88
89
  let { roomRecvIPs, consume_sockets, connectIps } = parameters;
89
90
  const ipsToConnect: string[] = [];
90
91
 
92
+ // Busy node -> backup map: new streams are preferred from the backup (consumer-backup-v1).
93
+ rememberConsumerBackups(alt_domains);
94
+
91
95
  try {
92
96
  consume_sockets = parameters.getUpdatedAllParams().consume_sockets;
93
97
 
@@ -1,4 +1,9 @@
1
1
  import { PreJoinPageParameters } from "../../types/types";
2
+ import {
3
+ describeMediaSocketError,
4
+ isMediaSocketConnectError,
5
+ isNetworkOnlyMediaSocketError,
6
+ } from "../../sockets/mediaSocketErrors";
2
7
  import type { Socket } from "socket.io-client";
3
8
  import Cookies from "universal-cookie";
4
9
 
@@ -148,15 +153,19 @@ export const checkLimitsAndMakeRequestWithStorage: CheckLimitsAndMakeRequestWith
148
153
  } catch (error) {
149
154
  console.error('Error connecting to socket:', error);
150
155
  parameters.showAlert?.({
151
- message: 'Unable to connect. Check your credentials and try again.',
156
+ message: describeMediaSocketError(error),
152
157
  type: 'danger',
153
- duration: 3000,
158
+ duration: isMediaSocketConnectError(error) ? 6000 : 3000,
154
159
  });
155
160
 
156
- let unsuccessfulAttempts = await readStoredNumber(storageAdapter, 'unsuccessfulAttempts');
157
- unsuccessfulAttempts += 1;
158
- await writeStoredNumber(storageAdapter, 'unsuccessfulAttempts', unsuccessfulAttempts);
159
- await writeStoredNumber(storageAdapter, 'lastRequestTimestamp', Date.now());
161
+ // Only a refusal or an unknown failure counts toward the attempt limit; a device/network problem is not a
162
+ // credential guess.
163
+ if (!isNetworkOnlyMediaSocketError(error)) {
164
+ let unsuccessfulAttempts = await readStoredNumber(storageAdapter, 'unsuccessfulAttempts');
165
+ unsuccessfulAttempts += 1;
166
+ await writeStoredNumber(storageAdapter, 'unsuccessfulAttempts', unsuccessfulAttempts);
167
+ await writeStoredNumber(storageAdapter, 'lastRequestTimestamp', Date.now());
168
+ }
160
169
  parameters.updateIsLoadingModalVisible(false);
161
170
  }
162
171
  };
@@ -244,14 +253,18 @@ export const checkLimitsAndMakeRequest = async ({
244
253
  duration: 3000,
245
254
  });
246
255
  }
247
- } catch {
256
+ } catch (error) {
248
257
  parameters.showAlert?.({
249
- message: "Unable to connect. Check your credentials and try again.",
258
+ message: describeMediaSocketError(error),
250
259
  type: "danger",
251
- duration: 3000,
260
+ duration: isMediaSocketConnectError(error) ? 6000 : 3000,
252
261
  });
253
- unsuccessfulAttempts += 1;
254
- cookies.set("unsuccessfulAttempts", unsuccessfulAttempts.toString());
262
+ // Only a refusal or an unknown failure counts toward the attempt limit; a device/network problem is not a
263
+ // credential guess.
264
+ if (!isNetworkOnlyMediaSocketError(error)) {
265
+ unsuccessfulAttempts += 1;
266
+ cookies.set("unsuccessfulAttempts", unsuccessfulAttempts.toString());
267
+ }
255
268
  parameters.updateIsLoadingModalVisible(false);
256
269
  }
257
270
  };
@@ -27,6 +27,7 @@ export const createResponseJoinRoom: CreateResponseJoinRoomType = async ({
27
27
  localRoom,
28
28
  }: CreateResponseJoinRoomOptions): Promise<ResponseJoinRoom> => {
29
29
  return {
30
+ capacity: localRoom.capacity,
30
31
  rtpCapabilities: localRoom.rtpCapabilities ?? null,
31
32
  success: localRoom.rtpCapabilities !== null,
32
33
  roomRecvIPs: [],
@@ -44,4 +45,4 @@ export const createResponseJoinRoom: CreateResponseJoinRoomType = async ({
44
45
  suspended: false,
45
46
  noAdmin: localRoom.hostNotJoined,
46
47
  };
47
- };
48
+ };
@@ -1,4 +1,9 @@
1
1
  import type { Socket } from 'socket.io-client';
2
+ import {
3
+ describeMediaSocketError,
4
+ isMediaSocketConnectError,
5
+ isNetworkOnlyMediaSocketError,
6
+ } from '../../sockets/mediaSocketErrors';
2
7
  import Cookies from 'universal-cookie';
3
8
  import type { PreJoinPageParameters } from '../../types/types';
4
9
 
@@ -183,16 +188,20 @@ export async function handleWelcomeRequest({
183
188
  });
184
189
  }
185
190
  }
186
- } catch {
191
+ } catch (error) {
187
192
  showAlert?.({
188
- message: 'Unable to connect. Check your credentials and try again.',
193
+ message: describeMediaSocketError(error),
189
194
  type: 'danger',
190
- duration: 3000,
195
+ duration: isMediaSocketConnectError(error) ? 6000 : 3000,
191
196
  });
192
197
 
193
- unsuccessfulAttempts += 1;
194
- cookies.set('unsuccessfulAttempts', unsuccessfulAttempts.toString());
195
- cookies.set('lastRequestTimestamp', Date.now().toString());
198
+ // Only a refusal or an unknown failure counts toward the attempt limit; a device/network problem is not a
199
+ // credential guess.
200
+ if (!isNetworkOnlyMediaSocketError(error)) {
201
+ unsuccessfulAttempts += 1;
202
+ cookies.set('unsuccessfulAttempts', unsuccessfulAttempts.toString());
203
+ cookies.set('lastRequestTimestamp', Date.now().toString());
204
+ }
196
205
  updateIsLoadingModalVisible(false);
197
206
  }
198
207
  }
@@ -2,6 +2,13 @@
2
2
  // Socket manager for media socket.
3
3
  import { MeetingRoomParams, RecordingParams } from '../types/types';
4
4
  import io, { Socket } from 'socket.io-client'; // Importing socket type
5
+ import {
6
+ MediaSocketConnectError,
7
+ MediaSocketConnectPhase,
8
+ MediaConnectionRejectionReason,
9
+ currentOnlineState,
10
+ normalizeRejectionReason,
11
+ } from './mediaSocketErrors';
5
12
 
6
13
  /**
7
14
  * Validates the provided API key or token.
@@ -62,6 +69,10 @@ export type ConnectLocalSocketType = (options: ConnectLocalSocketOptions) => Pro
62
69
  * @param {string} options.link - The socket link.
63
70
  *
64
71
  * @returns {Promise<Socket>} A promise that resolves to the connected socket.
72
+ * @throws {MediaSocketConnectError} When the connection fails. `phase` says where (`before-open`, `after-open`,
73
+ * `rejected`, `timeout`), `reason`/`stage` carry the server's `connection-rejected` verdict, `elapsedMs` and `online`
74
+ * help tell a device/network problem from a refusal, and `retryable` is false for rejections that cannot succeed
75
+ * with the same credentials. `describeMediaSocketError(error)` turns it into a user-facing sentence.
65
76
  *
66
77
  * @example
67
78
  * ```typescript
@@ -135,16 +146,32 @@ async function connectSocket(
135
146
  });
136
147
  }
137
148
 
138
- // Dispose failed handshakes immediately. Otherwise Socket.IO continues
139
- // reconnecting a client that was never handed to the room lifecycle.
149
+ // Every path below runs at most once, clears handshake listeners/timers,
150
+ // and closes failed clients so Socket.IO cannot keep reconnecting an
151
+ // orphan that the room lifecycle never received.
140
152
  let settled = false;
141
153
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
154
+ const startedAt = Date.now();
155
+ let transportOpened = false;
156
+ let rejection: { reason: MediaConnectionRejectionReason; stage?: string } | null = null;
142
157
  const cleanupHandshake = () => {
143
158
  if (timeoutHandle) clearTimeout(timeoutHandle);
144
159
  socket.off('connection-success', handleConnectionSuccess);
145
160
  socket.off('connect_error', handleConnectError);
146
161
  socket.off('disconnect', handleDisconnectBeforeReady);
162
+ socket.off('connect', handleTransportOpen);
163
+ socket.off('connection-rejected', handleConnectionRejected);
147
164
  };
165
+ const failure = (phase: MediaSocketConnectPhase, transportError?: string) =>
166
+ new MediaSocketConnectError({
167
+ phase: rejection ? 'rejected' : phase,
168
+ reason: rejection?.reason,
169
+ stage: rejection?.stage,
170
+ elapsedMs: Date.now() - startedAt,
171
+ online: currentOnlineState(),
172
+ transportError,
173
+ link,
174
+ });
148
175
  const settle = (action: () => void) => {
149
176
  if (settled) return;
150
177
  settled = true;
@@ -152,6 +179,7 @@ async function connectSocket(
152
179
  action();
153
180
  };
154
181
 
182
+ // Handle socket connection events
155
183
  const handleConnectionSuccess = ({ socketId }: { socketId: string }) => {
156
184
  //check if link contains mediasfu.com and contains more than one c
157
185
  let conn = 'media';
@@ -170,24 +198,47 @@ async function connectSocket(
170
198
  const handleConnectError = (error: Error) => {
171
199
  settle(() => {
172
200
  socket.disconnect();
173
- reject(new Error('Error connecting to media socket: ' + error.message));
201
+ reject(failure(transportOpened ? 'after-open' : 'before-open', error?.message));
174
202
  });
175
203
  };
176
204
 
205
+ const handleTransportOpen = () => {
206
+ transportOpened = true;
207
+ };
208
+
209
+ // Media admission v2: the node says why before it closes the handshake. Socket.IO's own reconnection never
210
+ // retries a server-side close, and settling here stops this attempt from being retried as a network error.
211
+ const handleConnectionRejected = (payload?: { reason?: string; stage?: string }) => {
212
+ rejection = {
213
+ reason: normalizeRejectionReason(payload?.reason),
214
+ stage: typeof payload?.stage === 'string' ? payload.stage : undefined,
215
+ };
216
+ settle(() => {
217
+ try {
218
+ socket.disconnect();
219
+ } catch {
220
+ // Already closing.
221
+ }
222
+ reject(failure('rejected'));
223
+ });
224
+ };
225
+
226
+ // The transport can connect and then be dropped by the server before it
227
+ // ever sends `connection-success` — a rejected room, an expired token, a
228
+ // bad handshake. Without this the promise stayed pending forever, so the
229
+ // caller's loading modal never came down and no error was ever surfaced.
177
230
  const handleDisconnectBeforeReady = (reason: string) => {
178
- settle(() =>
179
- reject(
180
- new Error(
181
- 'Media socket closed before the room was ready (' + reason + ').'
182
- )
183
- )
184
- );
231
+ settle(() => reject(failure('after-open', reason)));
185
232
  };
186
233
 
187
234
  socket.on('connection-success', handleConnectionSuccess);
188
235
  socket.on('connect_error', handleConnectError);
189
236
  socket.on('disconnect', handleDisconnectBeforeReady);
237
+ socket.on('connect', handleTransportOpen);
238
+ socket.on('connection-rejected', handleConnectionRejected);
190
239
 
240
+ // Last resort, for the case where the socket neither connects, errors nor
241
+ // disconnects. Nothing above guarantees a settle on its own.
191
242
  timeoutHandle = setTimeout(() => {
192
243
  settle(() => {
193
244
  try {
@@ -195,7 +246,7 @@ async function connectSocket(
195
246
  } catch {
196
247
  // Nothing useful to do here.
197
248
  }
198
- reject(new Error('Timed out connecting to media socket.'));
249
+ reject(failure('timeout'));
199
250
  });
200
251
  }, CONNECT_TIMEOUT_MS);
201
252
  });
@@ -237,6 +288,11 @@ async function connectLocalSocket({ link }: ConnectLocalSocketOptions): Promise<
237
288
  socket = io(`${link}/media`, {
238
289
  transports: ['websocket'],
239
290
  });
291
+ const startedAt = Date.now();
292
+ let transportOpened = false;
293
+ socket.on('connect', () => {
294
+ transportOpened = true;
295
+ });
240
296
 
241
297
 
242
298
  // Handle socket connection events
@@ -245,7 +301,15 @@ async function connectLocalSocket({ link }: ConnectLocalSocketOptions): Promise<
245
301
  });
246
302
 
247
303
  socket.on('connect_error', (error: Error) => {
248
- reject(new Error('Error connecting to media socket: ' + error.message));
304
+ reject(
305
+ new MediaSocketConnectError({
306
+ phase: transportOpened ? 'after-open' : 'before-open',
307
+ elapsedMs: Date.now() - startedAt,
308
+ online: currentOnlineState(),
309
+ transportError: error?.message,
310
+ link,
311
+ })
312
+ );
249
313
  });
250
314
  });
251
315
  }