@rapidmx/meet-plugin 0.14.1 → 0.16.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 (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -1,247 +1,335 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/push/GuestSignalingClient.ts`) -
6
- * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
7
- * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
8
- * its own doc comment. */
9
-
10
- /** What a participant is sending, and whether their hand is up. Announced in `hello` and re-sent as a `state`
11
- * message on every change, so a tile can show a muted microphone, an avatar for a camera that is off, and a raised
12
- * hand without inspecting the media itself (the far end of a `replaceTrack(null)` gives no dependable signal that
13
- * the track stopped). */
14
- export interface ParticipantState {
15
- audioOn: boolean;
16
- videoOn: boolean;
17
- handRaised: boolean;
18
- }
19
-
20
- /** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
21
- * arbitrary string from another participant is never rendered. */
22
- export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"] as const;
23
-
24
- /** Which media path this participant's own tab currently insists on, for every peer - see
25
- * `MeshConnectionManager.setTransportMode()`'s doc comment for what each one actually does:
26
- *
27
- * - `"auto"`: the default waterfall (direct, then TURN, then the server's own WebSocket relay) - unchanged behavior.
28
- * - `"p2p"`: direct connections only - no TURN server offered to ICE, and a pair that can't connect directly stays
29
- * `"failed"` rather than falling back to the WebSocket relay.
30
- * - `"relay"`: TURN-relayed WebRTC only (`RTCPeerConnection`'s own `iceTransportPolicy: "relay"`) - still real
31
- * WebRTC, just never direct; a pair with no reachable TURN server stays `"failed"`, same "no further fallback" rule
32
- * as `"p2p"`.
33
- * - `"websocket"`: skips WebRTC negotiation entirely for every pair and goes straight to the server's own relay.
34
- *
35
- * A purely personal, per-tab troubleshooting choice - never transmitted as a setting, and independent of whatever
36
- * every other participant has chosen for their own tab. */
37
- export type TransportMode = "auto" | "p2p" | "relay" | "websocket";
38
-
39
- /**
40
- * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
41
- * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
42
- * offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads"). `type` is a fixed
43
- * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
44
- * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
45
- *
46
- * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
47
- * unset - every participant is relevant); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
48
- * point-to-point (`to` is the intended peer's uid) - a recipient that isn't `to` (when set) ignores the message,
49
- * since a channel's pub/sub fans every message out to every subscriber, sender included.
50
- *
51
- * `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
52
- * purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
53
- * nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
54
- * protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
55
- * revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
56
- * enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
57
- * counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
58
- *
59
- * `restart-connection` is broadcast whenever a participant changes their own `TransportMode`
60
- * (`MeshConnectionManager.setTransportMode()`): every connection of theirs needs rebuilding under the new policy,
61
- * and a recipient can't rebuild its own half of a pair unilaterally without the other side doing the same at
62
- * (almost) the same moment, or the two sides end up negotiating against two different `RTCPeerConnection`s. No
63
- * payload beyond the envelope - `from` already says whose connection to rebuild.
64
- */
65
- export interface SignalMessage {
66
- type: "video-meeting-signal";
67
- kind:
68
- | "hello"
69
- | "bye"
70
- | "offer"
71
- | "answer"
72
- | "ice-candidate"
73
- | "presenter-claim"
74
- | "presenter-release"
75
- | "state"
76
- | "reaction"
77
- | "relay-fallback"
78
- | "mute-request"
79
- | "kicked"
80
- | "talking-stick"
81
- | "restart-connection";
82
- /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
83
- * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
84
- * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
85
- * exactly that uid. */
86
- from: string;
87
- /** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
88
- * two participants, not one that ignores its own messages. A `peer` that doesn't start with `<from>~` is
89
- * ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
90
- * identified by `from`). */
91
- peer?: string;
92
- /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
93
- to?: string;
94
- /** `hello` only - the display name the sender chose in the lobby. */
95
- name?: string;
96
- /** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
97
- state?: ParticipantState;
98
- /** `reaction` only - one of `REACTION_EMOJIS`. */
99
- emoji?: string;
100
- /** `talking-stick` only - whether talking-stick mode is currently on. */
101
- active?: boolean;
102
- /** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
103
- * Absent when `active` is `false`, or when nobody holds it yet. */
104
- holder?: string;
105
- /** `offer`/`answer` only. */
106
- sdp?: RTCSessionDescriptionInit;
107
- /** `ice-candidate` only. */
108
- candidate?: RTCIceCandidateInit;
109
- }
110
-
111
- /** What `MeshConnectionManager` needs from a signaling transport - implemented by
112
- * `apps/shared/push/GuestSignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
113
- * tests (see `test/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
114
- export interface SignalingChannel {
115
- send(message: SignalMessage): void;
116
- /** Calls `handler` with every message received on the channel (including, per the pub/sub fan-out this
117
- * transport is built on, this same client's own sends - `MeshConnectionManager` filters those out itself
118
- * rather than relying on the transport to). Returns the function that stops listening. */
119
- onMessage(handler: (message: SignalMessage) => void): () => void;
120
- }
121
-
122
- /** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
123
- * since a single `getStats()` snapshot has no "since when" to divide by; a caller wanting a bitrate keeps the
124
- * previous sample itself and divides the difference by the elapsed time (see `MeshConnectionManager`'s poller).
125
- * `undefined` for anything this browser (or this stream's direction - an audio-only connection reports nothing for
126
- * video, and vice versa) doesn't report. */
127
- export interface RtpStreamDiagnostics {
128
- packetsLost?: number;
129
- /** Seconds. */
130
- jitter?: number;
131
- bytesSent?: number;
132
- bytesReceived?: number;
133
- }
134
-
135
- /** One point-in-time sample of a connection's own quality stats, for the diagnostics panel. */
136
- export interface ConnectionDiagnostics {
137
- /** Seconds - the selected candidate pair's own current round-trip time. `undefined` when this browser doesn't
138
- * report one (a connection still connecting, or simply not supported). */
139
- roundTripTimeSeconds?: number;
140
- audio: RtpStreamDiagnostics;
141
- video: RtpStreamDiagnostics;
142
- }
143
-
144
- /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
145
- * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
146
- * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
147
- * `RTCRtpSender` already satisfies this shape. */
148
- export interface RTCRtpSenderLike {
149
- track: MediaStreamTrack | null;
150
- replaceTrack(track: MediaStreamTrack | null): Promise<void>;
151
- }
152
-
153
- /** The subset of `RTCPeerConnection` `MeshConnectionManager` uses - what a test's fake implements, and what
154
- * `apps/shared/webrtc/realPeerConnection.ts` adapts the real browser constructor to. Event handlers are plain settable
155
- * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
156
- * this stands in for and keeping a test fake to the bare minimum. */
157
- export interface RTCPeerConnectionLike {
158
- /** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
159
- * nothing until `replaceTrack()` supplies it otherwise. */
160
- addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): { sender: RTCRtpSenderLike };
161
- /** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
162
- * send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
163
- * transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
164
- * unused, and everything the answerer sends is silently lost.) */
165
- claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
166
- createOffer(): Promise<RTCSessionDescriptionInit>;
167
- createAnswer(): Promise<RTCSessionDescriptionInit>;
168
- setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
169
- setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
170
- addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
171
- close(): void;
172
- onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
173
- ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
174
- onconnectionstatechange: (() => void) | null;
175
- connectionState: string;
176
- /** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
177
- * `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
178
- connectionType(): Promise<"p2p" | "turn" | "turn-tcp" | "unknown">;
179
- /** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - polled
180
- * periodically and independently of `connectionType()` (see `MeshConnectionManager`'s doc comment). */
181
- collectDiagnostics(): Promise<ConnectionDiagnostics>;
182
- }
183
-
184
- /** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
185
- *
186
- * - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
187
- * - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
188
- * - `"turn-tcp"`: the same, but the relay hop itself is TCP (or `turns:`, TLS being TCP-based too) rather than UDP -
189
- * a network that blocks UDP outright. TCP's reliable, ordered delivery means one lost or delayed packet stalls
190
- * everything queued behind it instead of just being dropped, so this can sound like pauses rather than the clicks
191
- * plain packet loss would - worth telling apart from ordinary `"turn"` for exactly that reason.
192
- * - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
193
- * `apps/shared/relay/`), the last resort when neither of the above connects.
194
- * - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
195
- export type MediaTransport = "connecting" | "p2p" | "turn" | "turn-tcp" | "websocket" | "failed";
196
-
197
- /** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
198
- * it) - declared here so the mesh, and its tests, depend on the shape alone. */
199
- export interface RelayTransportLike {
200
- /** `false` when this browser cannot do it (no WebSocket, no WebCodecs) - the mesh then never falls back to it. */
201
- readonly supported: boolean;
202
- /** Starts receiving `peerId`'s media. `onStream` is given the `MediaStream` that plays it. */
203
- receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
204
- stopReceivingFrom(peerId: string): void;
205
- /** Starts or stops publishing the local tracks to the relay. */
206
- setSending(active: boolean): void;
207
- /** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
208
- setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
209
- close(): void;
210
- }
211
-
212
- /** `iceTransportPolicy` mirrors the real `RTCConfiguration` field of the same name - `"relay"` forces ICE to use
213
- * only a TURN relay candidate, never a direct one (`TransportMode.relay`); omitted (or `"all"`) is the ordinary,
214
- * unconstrained default. */
215
- export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[]; iceTransportPolicy?: "all" | "relay" }) => RTCPeerConnectionLike;
216
-
217
- /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
218
- * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
219
- export interface MeshParticipant extends ParticipantState {
220
- uid: string;
221
- name: string;
222
- /** How this participant's media currently reaches the local one - see `MediaTransport`. */
223
- transport: MediaTransport;
224
- /** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
225
- * first poll completes (shortly after `transport` first leaves `"connecting"`), and never present at all for
226
- * a `"websocket"`-relayed participant (there is no `RTCPeerConnection` to poll). */
227
- diagnostics?: ConnectionDiagnostics;
228
- }
229
-
230
- export type MeshEvent =
231
- | { type: "participant-joined"; participant: MeshParticipant }
232
- | { type: "participant-updated"; participant: MeshParticipant }
233
- | { type: "participant-left"; uid: string }
234
- | { type: "remote-stream"; uid: string; stream: MediaStream }
235
- | { type: "hand-raised"; uid: string; name: string }
236
- | { type: "reaction"; uid: string; name: string; emoji: string }
237
- | { type: "presenter-changed"; uid: string | undefined }
238
- /** Someone (implicitly the host, on the client's own say-so - see `SignalMessage`'s doc comment) asked the local
239
- * participant to mute. The caller (`_CallView.tsx`) mutes only if currently unmuted; this never unmutes anyone. */
240
- | { type: "mute-requested" }
241
- /** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
242
- * "removed by the host" state rather than the ordinary "you left" one. */
243
- | { type: "kicked" }
244
- /** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
245
- * `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
246
- * `false`, or (transiently) if whoever held it has since left the call. */
247
- | { type: "talking-stick-changed"; active: boolean; holder?: string };
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Shared types for `MeshConnectionManager.ts` and its signaling transport (`apps/shared/signaling/SignalingClient.ts`) -
6
+ * kept in their own module so a test can depend on the shapes alone. `SignalingClient` serves both a guest and an
7
+ * already-authenticated real caller (`VideoMeetingJoinResult.authenticated`) - see its own doc comment. */
8
+
9
+ /** What a participant is sending, and whether their hand is up. Announced in `hello` and re-sent as a `state`
10
+ * message on every change, so a tile can show a muted microphone, an avatar for a camera that is off, and a raised
11
+ * hand without inspecting the media itself (the far end of a `replaceTrack(null)` gives no dependable signal that
12
+ * the track stopped). */
13
+ export interface ParticipantState {
14
+ audioOn: boolean;
15
+ videoOn: boolean;
16
+ handRaised: boolean;
17
+ }
18
+
19
+ /** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
20
+ * arbitrary string from another participant is never rendered. */
21
+ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"] as const;
22
+
23
+ /** Which media path this participant's own tab currently insists on, for every peer - see
24
+ * `MeshConnectionManager.setTransportMode()`'s doc comment for what each one actually does:
25
+ *
26
+ * - `"auto"`: the default waterfall (direct, then TURN, then the server's own WebSocket relay) - unchanged behavior.
27
+ * - `"p2p"`: direct connections only - no TURN server offered to ICE, and a pair that can't connect directly stays
28
+ * `"failed"` rather than falling back to the WebSocket relay.
29
+ * - `"relay"`: TURN-relayed WebRTC only (`RTCPeerConnection`'s own `iceTransportPolicy: "relay"`) - still real
30
+ * WebRTC, just never direct; a pair with no reachable TURN server stays `"failed"`, same "no further fallback" rule
31
+ * as `"p2p"`.
32
+ * - `"websocket"`: skips WebRTC negotiation entirely for every pair and goes straight to the server's own relay.
33
+ *
34
+ * A purely personal, per-tab troubleshooting choice - never transmitted as a setting, and independent of whatever
35
+ * every other participant has chosen for their own tab. */
36
+ export type TransportMode = "auto" | "p2p" | "relay" | "websocket";
37
+
38
+ /**
39
+ * One WebRTC signaling message, sent as the `data` of a `{"op":"signal"}` frame over the meeting's own signaling
40
+ * WebSocket (`apps/shared/signaling/SignalingClient.ts`, served by `BaseVideoMeetingRoute.signal()` and the backend's
41
+ * `SignalingHub`) and delivered to the other participants the same way. `type` is a fixed literal so a listener can
42
+ * cheaply recognize and skip anything that isn't one of these.
43
+ *
44
+ * `chat`/`whiteboard` (see `../collab/CollabSession.ts`) are broadcast like `reaction`.
45
+ *
46
+ * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction`/`talking-stick` are broadcast (`to` left
47
+ * unset - every other participant receives it); `offer`/`answer`/`ice-candidate`/`mute-request`/`kicked` are
48
+ * point-to-point (`to` is the intended peer's id) - the server delivers one only to the socket of that peer, so
49
+ * nobody else ever sees it. The sender never receives its own messages back either. (A recipient still ignores a
50
+ * message whose `to` isn't itself, and its own messages - harmless with this transport, and kept so
51
+ * `MeshConnectionManager` does not depend on how its transport routes.)
52
+ *
53
+ * `mute-request`/`kicked`/`talking-stick` are host moderation signals (see `MeshConnectionManager`'s doc comment) -
54
+ * purely cooperative, like every other message here: the recipient's own client decides whether to honor one, and
55
+ * nothing on the wire distinguishes a genuine host's message from any other participant's, since this client
56
+ * protocol has no host identity of its own to check against server-side. `kicked` is paired with the sender also
57
+ * revoking the recipient's server-side channel grant (`BaseVideoMeetingRoute.revokeChannelGrant()`), which is
58
+ * enforced and does not depend on the recipient's client cooperating; `talking-stick` has no server-side
59
+ * counterpart at all (see this module's doc comment on why it is purely an in-call runtime signal).
60
+ *
61
+ * `restart-connection` is broadcast whenever a participant changes their own `TransportMode`
62
+ * (`MeshConnectionManager.setTransportMode()`): every connection of theirs needs rebuilding under the new policy,
63
+ * and a recipient can't rebuild its own half of a pair unilaterally without the other side doing the same at
64
+ * (almost) the same moment, or the two sides end up negotiating against two different `RTCPeerConnection`s. No
65
+ * payload beyond the envelope - `from` already says whose connection to rebuild.
66
+ */
67
+ export interface SignalMessage {
68
+ type: "video-meeting-signal";
69
+ kind:
70
+ | "hello"
71
+ | "bye"
72
+ | "offer"
73
+ | "answer"
74
+ | "ice-candidate"
75
+ | "presenter-claim"
76
+ | "presenter-release"
77
+ | "state"
78
+ | "reaction"
79
+ | "relay-fallback"
80
+ | "mute-request"
81
+ | "kicked"
82
+ | "talking-stick"
83
+ | "restart-connection"
84
+ | "chat"
85
+ | "whiteboard"
86
+ | "collab-sync-request"
87
+ | "collab-sync"
88
+ | "whiteboard-cursor";
89
+ /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
90
+ * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server overwrites whatever the sender put
91
+ * here with the uid its signaling socket authenticated as (it stops one participant speaking as another), so a
92
+ * received message's `from` is always that uid. */
93
+ from: string;
94
+ /** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
95
+ * two participants, not one that ignores its own messages. The server overwrites it with the peer id the sender's
96
+ * socket announced in its `hello` (which must be `<from>` or start with `<from>~`), so every received message
97
+ * carries one; a received `peer` that is neither is still ignored, so it can't name someone else's tab, and one
98
+ * that is absent identifies the participant by `from`. */
99
+ peer?: string;
100
+ /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
101
+ to?: string;
102
+ /** `hello` only - the display name the sender chose in the lobby. */
103
+ name?: string;
104
+ /** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
105
+ state?: ParticipantState;
106
+ /** `reaction` only - one of `REACTION_EMOJIS`. */
107
+ emoji?: string;
108
+ /** `talking-stick` only - whether talking-stick mode is currently on. */
109
+ active?: boolean;
110
+ /** `talking-stick` only - the peer id (`MeshParticipant.uid`/this tab's own id) currently holding the stick.
111
+ * Absent when `active` is `false`, or when nobody holds it yet. */
112
+ holder?: string;
113
+ /** `offer`/`answer` only. */
114
+ sdp?: RTCSessionDescriptionInit;
115
+ /** `ice-candidate` only. */
116
+ candidate?: RTCIceCandidateInit;
117
+ /** `chat` only - a `ChatPayload` (`../collab/protocol.ts`), validated by `parseChat()` before use. Typed `unknown`
118
+ * since it is another participant's input until then; the same goes for the next two. */
119
+ chat?: unknown;
120
+ /** `whiteboard` only - a `WhiteboardOp`, validated by `parseWhiteboardOp()`. */
121
+ whiteboard?: unknown;
122
+ /** `collab-sync` only - one `SyncPayload` part of a newcomer's catch-up, validated by `parseSync()`. `collab-sync`
123
+ * and `collab-sync-request` are point-to-point (`to` set) - see `CollabSession`'s doc comment. */
124
+ sync?: unknown;
125
+ /** `whiteboard-cursor` only - a `CursorPayload`, validated by `parseCursor()`; broadcast, never stored. */
126
+ cursor?: unknown;
127
+ }
128
+
129
+ /** What `MeshConnectionManager` needs from a signaling transport - implemented by
130
+ * `apps/shared/signaling/SignalingClient.ts` for a real call, and by an in-memory fake for `MeshConnectionManager`'s own
131
+ * tests (see `test/apps/shared/webrtc/MeshConnectionManager.test.ts`), which never opens a real network connection. */
132
+ export interface SignalingChannel {
133
+ send(message: SignalMessage): void;
134
+ /** Calls `handler` with every message received on the channel. `SignalingClient` never delivers this client's own
135
+ * sends, nor a `to`-addressed message meant for another peer, but `MeshConnectionManager` filters both out itself
136
+ * rather than relying on the transport to (its tests' fake echoes everything to everyone). Returns the function
137
+ * that stops listening. */
138
+ onMessage(handler: (message: SignalMessage) => void): () => void;
139
+ }
140
+
141
+ /** One kind's RTP stream stats, as of one sample - cumulative counters (`bytesSent`/`bytesReceived`), not a rate,
142
+ * since a single `getStats()` snapshot has no "since when" to divide by; a caller wanting a bitrate keeps the
143
+ * previous sample itself and divides the difference by the elapsed time (see `MeshConnectionManager`'s poller).
144
+ * `undefined` for anything this browser (or this stream's direction - an audio-only connection reports nothing for
145
+ * video, and vice versa) doesn't report. */
146
+ export interface RtpStreamDiagnostics {
147
+ packetsLost?: number;
148
+ /** Seconds. */
149
+ jitter?: number;
150
+ bytesSent?: number;
151
+ bytesReceived?: number;
152
+ }
153
+
154
+ /** One point-in-time sample of a connection's own quality stats, for the diagnostics panel. */
155
+ export interface ConnectionDiagnostics {
156
+ /** Seconds - the selected candidate pair's own current round-trip time. `undefined` when this browser doesn't
157
+ * report one (a connection still connecting, or simply not supported). */
158
+ roundTripTimeSeconds?: number;
159
+ audio: RtpStreamDiagnostics;
160
+ video: RtpStreamDiagnostics;
161
+ /** The WebSocket relay's own counters, present only for a `"websocket"` pair - `audio`/`video` then carry the
162
+ * subset of them that maps onto RTP stats (frames lost, bytes each way). */
163
+ relay?: RelayDiagnostics;
164
+ }
165
+
166
+ /** What the relay's sending half did with one kind of frame, cumulative since the relay transport was created. */
167
+ export interface RelaySendStats {
168
+ framesSent: number;
169
+ /** Frames encoded but not sent: the socket was not ready, backed up, or refused a fragment. */
170
+ framesDropped: number;
171
+ bytesSent: number;
172
+ }
173
+
174
+ /** What the relay's sending half has done, cumulative since the relay transport was created. It is per browser, not
175
+ * per peer: every relayed peer receives the same stream. */
176
+ export interface RelaySendDiagnostics {
177
+ /** Milliseconds of microphone audio the capture path delivered, against `audioCaptureWallMs` - the wall-clock time
178
+ * it was capturing for. Well short of it means capture is losing whole blocks. */
179
+ audioCapturedMs: number;
180
+ audioCaptureWallMs: number;
181
+ /** How much of `audioCapturedMs` was digital silence (see `SilenceMeter`) - a live microphone always has some
182
+ * noise, so a large share means the capture path is handing the encoder zero-filled blocks. */
183
+ audioSilentMs: number;
184
+ /** Milliseconds of captured audio not encoded because the encoder was too far behind. */
185
+ audioEncoderSkippedMs: number;
186
+ audio: RelaySendStats;
187
+ video: RelaySendStats;
188
+ }
189
+
190
+ /** What the relay's receiving half got of one kind of frame from one peer, cumulative. */
191
+ export interface RelayReceiveStats {
192
+ framesReceived: number;
193
+ /** Frames the sequence numbers say never arrived complete - dropped by the sender, the server or in transit. */
194
+ framesLost: number;
195
+ bytesReceived: number;
196
+ }
197
+
198
+ /** What the relay's receiving half has done with one peer's media, cumulative since it started receiving from them. */
199
+ export interface RelayReceiveDiagnostics {
200
+ audio: RelayReceiveStats & {
201
+ /** Milliseconds of decoded audio scheduled for playback. */
202
+ playedMs: number;
203
+ /** How much of `playedMs` was digital silence - silence that arrived inside the packets, i.e. the sender sent
204
+ * it, as opposed to `gapMs`. */
205
+ silentMs: number;
206
+ /** Times playback ran dry and restarted, and the total silence that inserted. */
207
+ gaps: number;
208
+ gapMs: number;
209
+ /** Packets dropped on arrival because the decoder was backed up or playback was already too far ahead. */
210
+ droppedLate: number;
211
+ /** How far ahead of the clock audio is scheduled right now - the receive buffer's current depth. */
212
+ bufferedMs: number;
213
+ };
214
+ video: RelayReceiveStats;
215
+ }
216
+
217
+ /** One sample of the relay's counters for one peer. */
218
+ export interface RelayDiagnostics {
219
+ /** The latest round trip from this browser to the relay server, in milliseconds - not to the peer: the relay has
220
+ * no direct path between the two, and each end only knows its own leg. `undefined` before the first pong, or for
221
+ * a server too old to answer pings. */
222
+ roundTripMs?: number;
223
+ send: RelaySendDiagnostics;
224
+ /** `undefined` when nothing is being received from this peer. */
225
+ receive?: RelayReceiveDiagnostics;
226
+ }
227
+
228
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
229
+ * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
230
+ * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
231
+ * `RTCRtpSender` already satisfies this shape. */
232
+ export interface RTCRtpSenderLike {
233
+ track: MediaStreamTrack | null;
234
+ replaceTrack(track: MediaStreamTrack | null): Promise<void>;
235
+ }
236
+
237
+ /** The subset of `RTCPeerConnection` `MeshConnectionManager` uses - what a test's fake implements, and what
238
+ * `apps/shared/webrtc/realPeerConnection.ts` adapts the real browser constructor to. Event handlers are plain settable
239
+ * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
240
+ * this stands in for and keeping a test fake to the bare minimum. */
241
+ export interface RTCPeerConnectionLike {
242
+ /** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
243
+ * nothing until `replaceTrack()` supplies it otherwise. */
244
+ addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): { sender: RTCRtpSenderLike };
245
+ /** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
246
+ * send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
247
+ * transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
248
+ * unused, and everything the answerer sends is silently lost.) */
249
+ claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
250
+ createOffer(): Promise<RTCSessionDescriptionInit>;
251
+ createAnswer(): Promise<RTCSessionDescriptionInit>;
252
+ setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
253
+ setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
254
+ addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
255
+ close(): void;
256
+ onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
257
+ ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
258
+ onconnectionstatechange: (() => void) | null;
259
+ connectionState: string;
260
+ /** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
261
+ * `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
262
+ connectionType(): Promise<"p2p" | "turn" | "turn-tcp" | "unknown">;
263
+ /** One point-in-time sample of this connection's own quality stats, for the diagnostics panel - polled
264
+ * periodically and independently of `connectionType()` (see `MeshConnectionManager`'s doc comment). */
265
+ collectDiagnostics(): Promise<ConnectionDiagnostics>;
266
+ }
267
+
268
+ /** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
269
+ *
270
+ * - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
271
+ * - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
272
+ * - `"turn-tcp"`: the same, but the relay hop itself is TCP (or `turns:`, TLS being TCP-based too) rather than UDP -
273
+ * a network that blocks UDP outright. TCP's reliable, ordered delivery means one lost or delayed packet stalls
274
+ * everything queued behind it instead of just being dropped, so this can sound like pauses rather than the clicks
275
+ * plain packet loss would - worth telling apart from ordinary `"turn"` for exactly that reason.
276
+ * - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
277
+ * `apps/shared/relay/`), the last resort when neither of the above connects.
278
+ * - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
279
+ export type MediaTransport = "connecting" | "p2p" | "turn" | "turn-tcp" | "websocket" | "failed";
280
+
281
+ /** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
282
+ * it) - declared here so the mesh, and its tests, depend on the shape alone. */
283
+ export interface RelayTransportLike {
284
+ /** `false` when this browser cannot do it (no WebSocket, no WebCodecs) - the mesh then never falls back to it. */
285
+ readonly supported: boolean;
286
+ /** Starts receiving `peerId`'s media. `onStream` is given the `MediaStream` that plays it. */
287
+ receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
288
+ stopReceivingFrom(peerId: string): void;
289
+ /** Starts or stops publishing the local tracks to the relay. */
290
+ setSending(active: boolean): void;
291
+ /** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
292
+ setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
293
+ /** The relay's counters as they stand for `peerId`, for the diagnostics panel. Optional so a test's fake relay
294
+ * needn't provide it; the mesh then shows nothing for a relayed pair. */
295
+ diagnostics?(peerId: string): RelayDiagnostics;
296
+ close(): void;
297
+ }
298
+
299
+ /** `iceTransportPolicy` mirrors the real `RTCConfiguration` field of the same name - `"relay"` forces ICE to use
300
+ * only a TURN relay candidate, never a direct one (`TransportMode.relay`); omitted (or `"all"`) is the ordinary,
301
+ * unconstrained default. */
302
+ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[]; iceTransportPolicy?: "all" | "relay" }) => RTCPeerConnectionLike;
303
+
304
+ /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
305
+ * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
306
+ export interface MeshParticipant extends ParticipantState {
307
+ uid: string;
308
+ name: string;
309
+ /** How this participant's media currently reaches the local one - see `MediaTransport`. */
310
+ transport: MediaTransport;
311
+ /** This connection's most recently polled quality stats, for the diagnostics panel - `undefined` until the
312
+ * first poll completes (shortly after `transport` first leaves `"connecting"`, or the pair moves to the relay).
313
+ * For a `"websocket"`-relayed participant they come from the relay's own counters (`relay`) rather than an
314
+ * `RTCPeerConnection`. */
315
+ diagnostics?: ConnectionDiagnostics;
316
+ }
317
+
318
+ export type MeshEvent =
319
+ | { type: "participant-joined"; participant: MeshParticipant }
320
+ | { type: "participant-updated"; participant: MeshParticipant }
321
+ | { type: "participant-left"; uid: string }
322
+ | { type: "remote-stream"; uid: string; stream: MediaStream }
323
+ | { type: "hand-raised"; uid: string; name: string }
324
+ | { type: "reaction"; uid: string; name: string; emoji: string }
325
+ | { type: "presenter-changed"; uid: string | undefined }
326
+ /** Someone (implicitly the host, on the client's own say-so - see `SignalMessage`'s doc comment) asked the local
327
+ * participant to mute. The caller (`_CallView.tsx`) mutes only if currently unmuted; this never unmutes anyone. */
328
+ | { type: "mute-requested" }
329
+ /** The local participant was removed from the call. The caller tears down the mesh and shows a distinct
330
+ * "removed by the host" state rather than the ordinary "you left" one. */
331
+ | { type: "kicked" }
332
+ /** Talking-stick mode turned on or off, or the stick changed hands - see this module's doc comment and
333
+ * `MeshConnectionManager.setTalkingStick()`. `holder` is the peer id now holding it; absent while `active` is
334
+ * `false`, or (transiently) if whoever held it has since left the call. */
335
+ | { type: "talking-stick-changed"; active: boolean; holder?: string };