@rapidmx/meet-plugin 0.2.0 → 0.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 (46) hide show
  1. package/apps/meet/[token].tsx +51 -46
  2. package/apps/meet/_CallControls.tsx +255 -32
  3. package/apps/meet/_CallView.tsx +275 -115
  4. package/apps/meet/_MeetLobby.tsx +111 -142
  5. package/apps/meet/_ParticipantTile.tsx +51 -19
  6. package/apps/meet/_icons.tsx +54 -0
  7. package/apps/shared/media/chime.ts +74 -0
  8. package/apps/shared/media/deviceMedia.ts +3 -13
  9. package/apps/shared/media/useLocalMedia.ts +337 -0
  10. package/apps/shared/push/GuestSignalingClient.ts +2 -0
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +212 -44
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +52 -15
  14. package/dist/apps/meet/[token].js +31 -19
  15. package/dist/apps/meet/_CallControls.d.ts +20 -7
  16. package/dist/apps/meet/_CallControls.js +72 -3
  17. package/dist/apps/meet/_CallView.d.ts +23 -4
  18. package/dist/apps/meet/_CallView.js +141 -58
  19. package/dist/apps/meet/_MeetLobby.d.ts +16 -19
  20. package/dist/apps/meet/_MeetLobby.js +49 -99
  21. package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
  22. package/dist/apps/meet/_ParticipantTile.js +12 -9
  23. package/dist/apps/meet/_icons.d.ts +15 -0
  24. package/dist/apps/meet/_icons.js +16 -0
  25. package/dist/apps/shared/media/chime.d.ts +31 -0
  26. package/dist/apps/shared/media/chime.js +49 -0
  27. package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
  28. package/dist/apps/shared/media/deviceMedia.js +3 -12
  29. package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
  30. package/dist/apps/shared/media/useLocalMedia.js +259 -0
  31. package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +57 -17
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +52 -15
  36. package/dist/apps/shared/webrtc/types.js +3 -1
  37. package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
  38. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  39. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
  40. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
  41. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
  42. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
  43. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
  44. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
  45. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
  46. package/package.json +181 -181
@@ -15,6 +15,20 @@
15
15
  * both sides independently compute the same answer with no coordination round trip, and exactly one side ever
16
16
  * calls `createOffer()` for a given pair.
17
17
  *
18
+ * ## Media: two permanent transceivers, tracks swapped in and out
19
+ *
20
+ * Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
21
+ * whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
22
+ * offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
23
+ * m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
24
+ * sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
25
+ * through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
26
+ * `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
27
+ * as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
28
+ * direction, for the whole call.)
29
+ * The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
30
+ * read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
31
+ *
18
32
  * ## Join announcement and roster discovery
19
33
  *
20
34
  * The channel has no history (a push event published while a socket was down is never replayed - see
@@ -26,6 +40,17 @@
26
40
  * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
27
41
  * ever discovers) rather than a risk of runaway flooding.
28
42
  *
43
+ * Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
44
+ * are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
45
+ * `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
46
+ * exists rather than dropped.
47
+ *
48
+ * ## Participant state, hands and reactions
49
+ *
50
+ * What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
51
+ * `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
52
+ * sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
53
+ *
29
54
  * ## Presenter (single-writer screen share)
30
55
  *
31
56
  * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
@@ -35,24 +60,37 @@
35
60
  * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
36
61
  * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
37
62
  *
38
- * ## Presentation mode's screen share
39
- *
40
- * Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
41
- * simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
42
- * the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
43
- * back to the camera track the same way.
63
+ * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
44
64
  */
45
- import type { MeshEvent, MeshParticipant, RTCPeerConnectionFactory, RTCPeerConnectionLike, SignalMessage, SignalingChannel } from "./types.js";
65
+ import {
66
+ type MeshEvent,
67
+ type MeshParticipant,
68
+ type ParticipantState,
69
+ type RTCPeerConnectionFactory,
70
+ type RTCPeerConnectionLike,
71
+ type RTCRtpSenderLike,
72
+ type SignalMessage,
73
+ type SignalingChannel,
74
+ REACTION_EMOJIS,
75
+ } from "./types.js";
46
76
 
47
77
  /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
48
78
  export function isOfferer(selfUid: string, peerUid: string): boolean {
49
79
  return selfUid < peerUid;
50
80
  }
51
81
 
52
- interface PeerState {
53
- uid: string;
54
- name: string;
82
+ /** How many peers' worth of early ICE candidates are held, and how many candidates per peer - bounds the memory a
83
+ * misbehaving participant could make this hold for peers that never materialize. */
84
+ const MAX_ORPHAN_PEERS = 32;
85
+ const MAX_ORPHAN_CANDIDATES = 64;
86
+
87
+ type MediaKind = "audio" | "video";
88
+
89
+ interface PeerState extends MeshParticipant {
55
90
  pc: RTCPeerConnectionLike;
91
+ /** Empty on an answerer until the offer arrives - see `handleOffer()`. */
92
+ senders: Partial<Record<MediaKind, RTCRtpSenderLike>>;
93
+ remoteStream: MediaStream;
56
94
  remoteDescriptionSet: boolean;
57
95
  pendingCandidates: RTCIceCandidateInit[];
58
96
  }
@@ -63,22 +101,40 @@ export interface MeshConnectionManagerOptions {
63
101
  iceServers: RTCIceServer[];
64
102
  channel: SignalingChannel;
65
103
  createPeerConnection: RTCPeerConnectionFactory;
66
- /** The local camera/microphone stream, attached to every new peer connection as it's created. */
67
- localStream: MediaStream;
104
+ /** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
105
+ * replaced later with `setLocalTrack()`. */
106
+ localAudioTrack?: MediaStreamTrack | null;
107
+ localVideoTrack?: MediaStreamTrack | null;
108
+ /** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
109
+ localState?: Partial<ParticipantState>;
110
+ /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
111
+ * `new MediaStream()`; a test supplies a fake. */
112
+ createMediaStream?: () => MediaStream;
68
113
  }
69
114
 
70
115
  export class MeshConnectionManager {
71
116
  private readonly peers = new Map<string, PeerState>();
72
117
  private readonly listeners = new Set<(event: MeshEvent) => void>();
118
+ private readonly orphanCandidates = new Map<string, RTCIceCandidateInit[]>();
119
+ private readonly localTracks: Record<MediaKind, MediaStreamTrack | null>;
120
+ private localState: ParticipantState;
73
121
  private unsubscribe: (() => void) | undefined;
74
122
  private started = false;
75
123
  private stopped = false;
76
124
  private currentPresenterUid: string | undefined;
77
125
 
78
- constructor(private readonly options: MeshConnectionManagerOptions) {}
126
+ constructor(private readonly options: MeshConnectionManagerOptions) {
127
+ this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
128
+ this.localState = {
129
+ audioOn: !!options.localAudioTrack,
130
+ videoOn: !!options.localVideoTrack,
131
+ handRaised: false,
132
+ ...options.localState,
133
+ };
134
+ }
79
135
 
80
136
  get participants(): MeshParticipant[] {
81
- return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
137
+ return [...this.peers.values()].map((peer) => toParticipant(peer));
82
138
  }
83
139
 
84
140
  get presenterUid(): string | undefined {
@@ -101,9 +157,9 @@ export class MeshConnectionManager {
101
157
  }
102
158
 
103
159
  /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
104
- * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
105
- * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
106
- * stream's lifecycle, only attaches it. */
160
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
161
+ * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
162
+ * lifecycle, only sends them. */
107
163
  stop(): void {
108
164
  if (this.stopped) {
109
165
  return;
@@ -118,20 +174,41 @@ export class MeshConnectionManager {
118
174
  peer.pc.close();
119
175
  }
120
176
  this.peers.clear();
177
+ this.orphanCandidates.clear();
121
178
  this.listeners.clear();
122
179
  }
123
180
 
124
- /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
125
- * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
126
- * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
127
- * it). */
128
- replaceLocalVideoTrack(track: MediaStreamTrack | null): void {
181
+ /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
182
+ * joins later - see this module's doc comment. */
183
+ setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void {
184
+ this.localTracks[kind] = track;
129
185
  for (const peer of this.peers.values()) {
130
- const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
131
- void sender?.replaceTrack(track);
186
+ void peer.senders[kind]?.replaceTrack(track);
132
187
  }
133
188
  }
134
189
 
190
+ /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
191
+ * actually changed. Before `start()` it only records the state, which `hello` then carries. */
192
+ setLocalState(partial: Partial<ParticipantState>): void {
193
+ const next = { ...this.localState, ...partial };
194
+ if (sameState(next, this.localState)) {
195
+ return;
196
+ }
197
+ this.localState = next;
198
+ if (this.started && !this.stopped) {
199
+ this.send({ kind: "state", state: next });
200
+ }
201
+ }
202
+
203
+ /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
204
+ sendReaction(emoji: string): boolean {
205
+ if (!isReactionEmoji(emoji)) {
206
+ return false;
207
+ }
208
+ this.send({ kind: "reaction", emoji });
209
+ return true;
210
+ }
211
+
135
212
  /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
136
213
  * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
137
214
  * rather than let a claim silently do nothing. */
@@ -156,7 +233,7 @@ export class MeshConnectionManager {
156
233
  }
157
234
 
158
235
  private sendHello(): void {
159
- this.send({ kind: "hello", name: this.options.selfName });
236
+ this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
160
237
  }
161
238
 
162
239
  private send(partial: Omit<SignalMessage, "type" | "from">): void {
@@ -198,25 +275,70 @@ export class MeshConnectionManager {
198
275
  case "presenter-release":
199
276
  this.handlePresenterRelease(message.from);
200
277
  return;
278
+ case "state":
279
+ this.handleState(message);
280
+ return;
281
+ case "reaction":
282
+ this.handleReaction(message);
283
+ return;
201
284
  }
202
285
  }
203
286
 
204
287
  private handleHello(message: SignalMessage): void {
205
- if (this.peers.has(message.from)) {
288
+ const known = this.peers.get(message.from);
289
+ if (known) {
290
+ // An offer that beat this hello created the peer under a placeholder name - fill in the real one.
291
+ this.updatePeer(known, message.name, message.state);
206
292
  return;
207
293
  }
208
- const name = message.name ?? message.from;
209
- const peer = this.createPeer(message.from, name);
210
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
294
+ const offerer = isOfferer(this.options.selfUid, message.from);
295
+ const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
296
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
211
297
  // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
212
298
  // doc comment on roster discovery.
213
299
  this.sendHello();
214
- if (isOfferer(this.options.selfUid, message.from)) {
300
+ if (offerer) {
215
301
  void this.initiateOffer(peer);
216
302
  }
217
303
  }
218
304
 
305
+ private handleState(message: SignalMessage): void {
306
+ const peer = this.peers.get(message.from);
307
+ if (peer) {
308
+ this.updatePeer(peer, undefined, message.state);
309
+ }
310
+ }
311
+
312
+ private handleReaction(message: SignalMessage): void {
313
+ const peer = this.peers.get(message.from);
314
+ if (peer && isReactionEmoji(message.emoji)) {
315
+ this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
316
+ }
317
+ }
318
+
319
+ /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
320
+ * hand going up) only when something actually changed. */
321
+ private updatePeer(peer: PeerState, name: string | undefined, state: ParticipantState | undefined): void {
322
+ const nextName = name ?? peer.name;
323
+ const next = state ? sanitizeState(state) : undefined;
324
+ const handWentUp = !!next && next.handRaised && !peer.handRaised;
325
+ if (nextName === peer.name && (!next || sameState(next, peer))) {
326
+ return;
327
+ }
328
+ peer.name = nextName;
329
+ if (next) {
330
+ peer.audioOn = next.audioOn;
331
+ peer.videoOn = next.videoOn;
332
+ peer.handRaised = next.handRaised;
333
+ }
334
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
335
+ if (handWentUp) {
336
+ this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
337
+ }
338
+ }
339
+
219
340
  private handleBye(uid: string): void {
341
+ this.orphanCandidates.delete(uid);
220
342
  const peer = this.peers.get(uid);
221
343
  if (!peer) {
222
344
  return;
@@ -230,23 +352,35 @@ export class MeshConnectionManager {
230
352
  }
231
353
  }
232
354
 
233
- private createPeer(uid: string, name: string): PeerState {
355
+ private createPeer(uid: string, name: string, state: ParticipantState | undefined, offerer: boolean): PeerState {
234
356
  const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
235
- const peer: PeerState = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
357
+ const peer: PeerState = {
358
+ uid,
359
+ name,
360
+ ...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
361
+ pc,
362
+ senders: offerer
363
+ ? {
364
+ audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
365
+ video: pc.addTransceiver("video", this.localTracks.video).sender,
366
+ }
367
+ : {},
368
+ remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
369
+ remoteDescriptionSet: false,
370
+ pendingCandidates: this.orphanCandidates.get(uid) ?? [],
371
+ };
372
+ this.orphanCandidates.delete(uid);
236
373
  this.peers.set(uid, peer);
237
- for (const track of this.options.localStream.getTracks()) {
238
- pc.addTrack(track, this.options.localStream);
239
- }
240
374
  pc.onicecandidate = (event) => {
241
375
  if (event.candidate) {
242
376
  this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
243
377
  }
244
378
  };
245
379
  pc.ontrack = (event) => {
246
- const stream = event.streams[0];
247
- if (stream) {
248
- this.emit({ type: "remote-stream", uid, stream });
380
+ if (!peer.remoteStream.getTracks().includes(event.track)) {
381
+ peer.remoteStream.addTrack(event.track);
249
382
  }
383
+ this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
250
384
  };
251
385
  pc.onconnectionstatechange = () => {
252
386
  if (pc.connectionState === "failed" || pc.connectionState === "closed") {
@@ -269,12 +403,23 @@ export class MeshConnectionManager {
269
403
  let peer = this.peers.get(message.from);
270
404
  const isNew = !peer;
271
405
  if (!peer) {
272
- peer = this.createPeer(message.from, message.from);
406
+ peer = this.createPeer(message.from, message.from, undefined, false);
273
407
  }
274
408
  await peer.pc.setRemoteDescription(message.sdp);
275
409
  this.flushPendingCandidates(peer);
410
+ if (!peer.senders.audio && !peer.senders.video) {
411
+ // The answerer's half of the media setup - see this module's doc comment.
412
+ peer.senders = peer.pc.claimTransceivers();
413
+ for (const kind of ["audio", "video"] as const) {
414
+ const track = this.localTracks[kind];
415
+ if (track) {
416
+ await peer.senders[kind]?.replaceTrack(track);
417
+ }
418
+ }
419
+ }
276
420
  if (isNew) {
277
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
421
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
422
+ this.sendHello();
278
423
  }
279
424
  const answer = await peer.pc.createAnswer();
280
425
  await peer.pc.setLocalDescription(answer);
@@ -296,10 +441,16 @@ export class MeshConnectionManager {
296
441
  }
297
442
  const peer = this.peers.get(message.from);
298
443
  if (!peer) {
299
- // No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
300
- // nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
301
- // connection (there are normally several). See this module's known-limitations note in the Phase 2
302
- // report.
444
+ // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
445
+ // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
446
+ const held = this.orphanCandidates.get(message.from);
447
+ if (held) {
448
+ if (held.length < MAX_ORPHAN_CANDIDATES) {
449
+ held.push(message.candidate);
450
+ }
451
+ } else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
452
+ this.orphanCandidates.set(message.from, [message.candidate]);
453
+ }
303
454
  return;
304
455
  }
305
456
  if (!peer.remoteDescriptionSet) {
@@ -348,3 +499,20 @@ export class MeshConnectionManager {
348
499
  }
349
500
  }
350
501
  }
502
+
503
+ function toParticipant(peer: PeerState): MeshParticipant {
504
+ return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
505
+ }
506
+
507
+ function sameState(a: ParticipantState, b: ParticipantState): boolean {
508
+ return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
509
+ }
510
+
511
+ /** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
512
+ function sanitizeState(state: ParticipantState): ParticipantState {
513
+ return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
514
+ }
515
+
516
+ function isReactionEmoji(emoji: unknown): emoji is (typeof REACTION_EMOJIS)[number] {
517
+ return (REACTION_EMOJIS as readonly unknown[]).includes(emoji);
518
+ }
@@ -12,8 +12,21 @@ import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js
12
12
  export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
13
13
  const pc = new RTCPeerConnection(config);
14
14
  const like: RTCPeerConnectionLike = {
15
- addTrack: (track, stream) => pc.addTrack(track, stream),
16
- getSenders: () => pc.getSenders(),
15
+ addTransceiver: (kind, track) => {
16
+ const { sender } = pc.addTransceiver(track ?? kind, { direction: "sendrecv" });
17
+ return { sender };
18
+ },
19
+ claimTransceivers: () => {
20
+ const senders: Partial<Record<"audio" | "video", RTCRtpSender>> = {};
21
+ for (const transceiver of pc.getTransceivers()) {
22
+ const kind = transceiver.receiver.track.kind as "audio" | "video";
23
+ if (!senders[kind]) {
24
+ transceiver.direction = "sendrecv";
25
+ senders[kind] = transceiver.sender;
26
+ }
27
+ }
28
+ return senders;
29
+ },
17
30
  createOffer: () => pc.createOffer(),
18
31
  createAnswer: () => pc.createAnswer(),
19
32
  setLocalDescription: (description) => pc.setLocalDescription(description),
@@ -31,7 +44,7 @@ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) =>
31
44
  // `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
32
45
  // adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
33
46
  pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
34
- pc.ontrack = (event) => like.ontrack?.({ streams: event.streams });
47
+ pc.ontrack = (event) => like.ontrack?.({ track: event.track });
35
48
  pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
36
49
  return like;
37
50
  };
@@ -7,6 +7,20 @@
7
7
  * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
8
8
  * its own doc comment. */
9
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
+
10
24
  /**
11
25
  * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
12
26
  * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
@@ -14,22 +28,35 @@
14
28
  * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
15
29
  * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
16
30
  *
17
- * `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every participant is
18
- * relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
19
- * that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
20
- * subscriber, sender included.
31
+ * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
32
+ * participant is relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) -
33
+ * a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to
34
+ * every subscriber, sender included.
21
35
  */
22
36
  export interface SignalMessage {
23
37
  type: "video-meeting-signal";
24
- kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
25
- /** The sender's uid - the real, already-authenticated caller's own uid when `join()` returned
26
- * `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
27
- * `VideoMeetingJoinResult.selfUid`). */
38
+ kind:
39
+ | "hello"
40
+ | "bye"
41
+ | "offer"
42
+ | "answer"
43
+ | "ice-candidate"
44
+ | "presenter-claim"
45
+ | "presenter-release"
46
+ | "state"
47
+ | "reaction";
48
+ /** The sender's per-call peer id - `CallView` derives it from `VideoMeetingJoinResult.selfUid` plus a random
49
+ * suffix, so the same account joining from two devices (or two tabs) is two participants, not one that ignores
50
+ * its own messages. */
28
51
  from: string;
29
52
  /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
30
53
  to?: string;
31
54
  /** `hello` only - the display name the sender chose in the lobby. */
32
55
  name?: string;
56
+ /** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
57
+ state?: ParticipantState;
58
+ /** `reaction` only - one of `REACTION_EMOJIS`. */
59
+ emoji?: string;
33
60
  /** `offer`/`answer` only. */
34
61
  sdp?: RTCSessionDescriptionInit;
35
62
  /** `ice-candidate` only. */
@@ -47,9 +74,10 @@ export interface SignalingChannel {
47
74
  onMessage(handler: (message: SignalMessage) => void): () => void;
48
75
  }
49
76
 
50
- /** The subset of `RTCRtpSender` `MeshConnectionManager.replaceLocalVideoTrack()` uses (presentation mode - see
51
- * this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
52
- * second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
77
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
78
+ * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
79
+ * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
80
+ * `RTCRtpSender` already satisfies this shape. */
53
81
  export interface RTCRtpSenderLike {
54
82
  track: MediaStreamTrack | null;
55
83
  replaceTrack(track: MediaStreamTrack | null): Promise<void>;
@@ -60,8 +88,14 @@ export interface RTCRtpSenderLike {
60
88
  * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
61
89
  * this stands in for and keeping a test fake to the bare minimum. */
62
90
  export interface RTCPeerConnectionLike {
63
- addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
64
- getSenders(): RTCRtpSenderLike[];
91
+ /** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
92
+ * nothing until `replaceTrack()` supplies it otherwise. */
93
+ addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): { sender: RTCRtpSenderLike };
94
+ /** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
95
+ * send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
96
+ * transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
97
+ * unused, and everything the answerer sends is silently lost.) */
98
+ claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
65
99
  createOffer(): Promise<RTCSessionDescriptionInit>;
66
100
  createAnswer(): Promise<RTCSessionDescriptionInit>;
67
101
  setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
@@ -69,7 +103,7 @@ export interface RTCPeerConnectionLike {
69
103
  addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
70
104
  close(): void;
71
105
  onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
72
- ontrack: ((event: { streams: readonly MediaStream[] }) => void) | null;
106
+ ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
73
107
  onconnectionstatechange: (() => void) | null;
74
108
  connectionState: string;
75
109
  }
@@ -78,13 +112,16 @@ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] })
78
112
 
79
113
  /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
80
114
  * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
81
- export interface MeshParticipant {
115
+ export interface MeshParticipant extends ParticipantState {
82
116
  uid: string;
83
117
  name: string;
84
118
  }
85
119
 
86
120
  export type MeshEvent =
87
121
  | { type: "participant-joined"; participant: MeshParticipant }
122
+ | { type: "participant-updated"; participant: MeshParticipant }
88
123
  | { type: "participant-left"; uid: string }
89
124
  | { type: "remote-stream"; uid: string; stream: MediaStream }
125
+ | { type: "hand-raised"; uid: string; name: string }
126
+ | { type: "reaction"; uid: string; name: string; emoji: string }
90
127
  | { type: "presenter-changed"; uid: string | undefined };
@@ -33,20 +33,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
33
33
  import React, { useState } from "react";
34
34
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
35
35
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
36
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
36
37
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
37
38
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
38
- import { default as MeetLobby } from "./_MeetLobby.js";
39
+ import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
+ import MeetLobby from "./_MeetLobby.js";
39
41
  import CallView from "./_CallView.js";
40
42
  import { joinMeeting } from "./_meetApi.js";
41
43
  export default function MeetJoinPage({ params }) {
42
44
  const { branding } = useBranding();
43
- return (_jsx(MeetPageShell, { branding: branding, children: _jsx(MeetJoinContent, { token: params.token }) }));
45
+ return _jsx(MeetJoinContent, { token: params.token, branding: branding });
44
46
  }
45
- function MeetJoinContent({ token }) {
47
+ /** Owns the camera and microphone (`useLocalMedia()`) for the whole visit, so the tracks the lobby previews are the
48
+ * ones the call sends. Every phase but the call is drawn inside the branded page shell; the call fills the whole
49
+ * window instead (see `_CallView.tsx`). */
50
+ function MeetJoinContent({ token, branding }) {
46
51
  const [phase, setPhase] = useState("loading");
47
52
  const [joinResult, setJoinResult] = useState(null);
48
- const [preferences, setPreferences] = useState(null);
53
+ const [name, setName] = useState("");
49
54
  const [loadError, setLoadError] = useState(null);
55
+ const media = useLocalMedia();
56
+ const { release } = media;
50
57
  React.useEffect(() => {
51
58
  let cancelled = false;
52
59
  joinMeeting(token)
@@ -71,31 +78,36 @@ function MeetJoinContent({ token }) {
71
78
  cancelled = true;
72
79
  };
73
80
  }, [token]);
74
- function handleJoin(prefs) {
75
- setPreferences(prefs);
81
+ function handleJoin(joinName) {
82
+ setName(joinName);
76
83
  setPhase("in-call");
77
84
  }
78
85
  function handleLeave() {
86
+ release();
79
87
  setPhase("ended");
80
88
  }
81
- if (phase === "loading") {
82
- return (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
83
- }
84
- if (phase === "not-found") {
85
- return (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
86
- }
87
- if (phase === "ended") {
88
- return (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted", children: "You can close this page now." })] }));
89
- }
90
- if (phase === "in-call" && joinResult && preferences) {
89
+ if (phase === "in-call" && joinResult) {
91
90
  return (_jsx(CallView, { channel: joinResult.meeting.uid,
92
91
  // `token` is present only for the common anonymous case (`authenticated: false`) - when the
93
92
  // caller's own existing session already authenticated them (`authenticated: true`), there is no
94
93
  // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
95
94
  // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
96
95
  // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
97
- token: joinResult.token, selfUid: joinResult.selfUid, selfName: preferences.name, iceServers: joinResult.iceServers, initialStream: preferences.stream, initialMicOn: preferences.micOn, initialCameraOn: preferences.cameraOn, onLeave: handleLeave }));
96
+ token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, media: media, onLeave: handleLeave }));
97
+ }
98
+ let content;
99
+ if (phase === "loading") {
100
+ content = (_jsx(MeetCard, { children: _jsx("p", { className: "text-base text-text-muted", children: "Loading\u2026" }) }));
101
+ }
102
+ else if (phase === "not-found") {
103
+ content = (_jsx(MeetCard, { children: _jsx(Alert, { children: loadError ?? "This meeting link isn't valid." }) }));
104
+ }
105
+ else if (phase === "ended") {
106
+ content = (_jsxs(MeetCard, { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight mb-2", children: "You left the meeting" }), _jsx("p", { className: "text-base text-text-muted mb-4", children: "You can close this page now, or rejoin." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => setPhase("lobby"), children: "Rejoin meeting" })] }));
107
+ }
108
+ else {
109
+ // phase === "lobby" (joinResult is always set by then - see the effect above).
110
+ content = (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, media: media, initialName: name, onJoin: handleJoin }) }));
98
111
  }
99
- // phase === "lobby" (join Result is always set by then - see the effect above).
100
- return (_jsx(MeetCard, { maxWidth: "max-w-4xl", children: _jsx(MeetLobby, { meeting: joinResult.meeting, onJoin: handleJoin }) }));
112
+ return _jsx(MeetPageShell, { branding: branding, children: content });
101
113
  }