@rapidmx/meet-plugin 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +276 -114
  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 +237 -55
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +58 -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 +24 -4
  18. package/dist/apps/meet/_CallView.js +142 -57
  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 +63 -18
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +58 -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,50 +60,87 @@
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
- export function isOfferer(selfUid: string, peerUid: string): boolean {
49
- return selfUid < peerUid;
78
+ export function isOfferer(selfId: string, peerId: string): boolean {
79
+ return selfId < peerId;
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
  }
59
97
 
60
98
  export interface MeshConnectionManagerOptions {
99
+ /** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
61
100
  selfUid: string;
101
+ /** This tab's identity in the call, `<selfUid>~<random>` - defaults to `selfUid`. Everything the manager keys on
102
+ * (the roster, who offers, who presents) is a peer id, so two tabs of one account are two participants. */
103
+ peerId?: string;
62
104
  selfName: string;
63
105
  iceServers: RTCIceServer[];
64
106
  channel: SignalingChannel;
65
107
  createPeerConnection: RTCPeerConnectionFactory;
66
- /** The local camera/microphone stream, attached to every new peer connection as it's created. */
67
- localStream: MediaStream;
108
+ /** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
109
+ * replaced later with `setLocalTrack()`. */
110
+ localAudioTrack?: MediaStreamTrack | null;
111
+ localVideoTrack?: MediaStreamTrack | null;
112
+ /** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
113
+ localState?: Partial<ParticipantState>;
114
+ /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
115
+ * `new MediaStream()`; a test supplies a fake. */
116
+ createMediaStream?: () => MediaStream;
68
117
  }
69
118
 
70
119
  export class MeshConnectionManager {
71
120
  private readonly peers = new Map<string, PeerState>();
72
121
  private readonly listeners = new Set<(event: MeshEvent) => void>();
122
+ private readonly orphanCandidates = new Map<string, RTCIceCandidateInit[]>();
123
+ private readonly localTracks: Record<MediaKind, MediaStreamTrack | null>;
124
+ private localState: ParticipantState;
73
125
  private unsubscribe: (() => void) | undefined;
74
126
  private started = false;
75
127
  private stopped = false;
76
128
  private currentPresenterUid: string | undefined;
129
+ private readonly selfId: string;
77
130
 
78
- constructor(private readonly options: MeshConnectionManagerOptions) {}
131
+ constructor(private readonly options: MeshConnectionManagerOptions) {
132
+ this.selfId = options.peerId ?? options.selfUid;
133
+ this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
134
+ this.localState = {
135
+ audioOn: !!options.localAudioTrack,
136
+ videoOn: !!options.localVideoTrack,
137
+ handRaised: false,
138
+ ...options.localState,
139
+ };
140
+ }
79
141
 
80
142
  get participants(): MeshParticipant[] {
81
- return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
143
+ return [...this.peers.values()].map((peer) => toParticipant(peer));
82
144
  }
83
145
 
84
146
  get presenterUid(): string | undefined {
@@ -101,9 +163,9 @@ export class MeshConnectionManager {
101
163
  }
102
164
 
103
165
  /** 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. */
166
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
167
+ * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
168
+ * lifecycle, only sends them. */
107
169
  stop(): void {
108
170
  if (this.stopped) {
109
171
  return;
@@ -118,28 +180,49 @@ export class MeshConnectionManager {
118
180
  peer.pc.close();
119
181
  }
120
182
  this.peers.clear();
183
+ this.orphanCandidates.clear();
121
184
  this.listeners.clear();
122
185
  }
123
186
 
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 {
187
+ /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
188
+ * joins later - see this module's doc comment. */
189
+ setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void {
190
+ this.localTracks[kind] = track;
129
191
  for (const peer of this.peers.values()) {
130
- const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
131
- void sender?.replaceTrack(track);
192
+ void peer.senders[kind]?.replaceTrack(track);
132
193
  }
133
194
  }
134
195
 
196
+ /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
197
+ * actually changed. Before `start()` it only records the state, which `hello` then carries. */
198
+ setLocalState(partial: Partial<ParticipantState>): void {
199
+ const next = { ...this.localState, ...partial };
200
+ if (sameState(next, this.localState)) {
201
+ return;
202
+ }
203
+ this.localState = next;
204
+ if (this.started && !this.stopped) {
205
+ this.send({ kind: "state", state: next });
206
+ }
207
+ }
208
+
209
+ /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
210
+ sendReaction(emoji: string): boolean {
211
+ if (!isReactionEmoji(emoji)) {
212
+ return false;
213
+ }
214
+ this.send({ kind: "reaction", emoji });
215
+ return true;
216
+ }
217
+
135
218
  /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
136
219
  * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
137
220
  * rather than let a claim silently do nothing. */
138
221
  claimPresenter(): boolean {
139
- if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.options.selfUid) {
222
+ if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.selfId) {
140
223
  return false;
141
224
  }
142
- this.currentPresenterUid = this.options.selfUid;
225
+ this.currentPresenterUid = this.selfId;
143
226
  this.send({ kind: "presenter-claim" });
144
227
  this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
145
228
  return true;
@@ -147,7 +230,7 @@ export class MeshConnectionManager {
147
230
 
148
231
  /** Releases presenter status - a no-op unless the local participant currently holds it. */
149
232
  releasePresenter(): void {
150
- if (this.currentPresenterUid !== this.options.selfUid) {
233
+ if (this.currentPresenterUid !== this.selfId) {
151
234
  return;
152
235
  }
153
236
  this.currentPresenterUid = undefined;
@@ -156,11 +239,11 @@ export class MeshConnectionManager {
156
239
  }
157
240
 
158
241
  private sendHello(): void {
159
- this.send({ kind: "hello", name: this.options.selfName });
242
+ this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
160
243
  }
161
244
 
162
245
  private send(partial: Omit<SignalMessage, "type" | "from">): void {
163
- this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
246
+ this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.selfId, ...partial });
164
247
  }
165
248
 
166
249
  private emit(event: MeshEvent): void {
@@ -169,11 +252,19 @@ export class MeshConnectionManager {
169
252
  }
170
253
  }
171
254
 
172
- private handleMessage(message: SignalMessage): void {
173
- if (message.type !== "video-meeting-signal" || message.from === this.options.selfUid) {
255
+ private handleMessage(raw: SignalMessage): void {
256
+ if (raw.type !== "video-meeting-signal") {
174
257
  return;
175
258
  }
176
- if (message.to !== undefined && message.to !== this.options.selfUid) {
259
+ // From here on `from` is the sender's peer id - see `SignalMessage.peer`.
260
+ if (raw.peer !== undefined && raw.peer !== raw.from && !raw.peer.startsWith(`${raw.from}~`)) {
261
+ return;
262
+ }
263
+ const message: SignalMessage = { ...raw, from: raw.peer ?? raw.from };
264
+ if (message.from === this.selfId) {
265
+ return;
266
+ }
267
+ if (message.to !== undefined && message.to !== this.selfId) {
177
268
  return;
178
269
  }
179
270
  switch (message.kind) {
@@ -198,25 +289,70 @@ export class MeshConnectionManager {
198
289
  case "presenter-release":
199
290
  this.handlePresenterRelease(message.from);
200
291
  return;
292
+ case "state":
293
+ this.handleState(message);
294
+ return;
295
+ case "reaction":
296
+ this.handleReaction(message);
297
+ return;
201
298
  }
202
299
  }
203
300
 
204
301
  private handleHello(message: SignalMessage): void {
205
- if (this.peers.has(message.from)) {
302
+ const known = this.peers.get(message.from);
303
+ if (known) {
304
+ // An offer that beat this hello created the peer under a placeholder name - fill in the real one.
305
+ this.updatePeer(known, message.name, message.state);
206
306
  return;
207
307
  }
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 } });
308
+ const offerer = isOfferer(this.selfId, message.from);
309
+ const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
310
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
211
311
  // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
212
312
  // doc comment on roster discovery.
213
313
  this.sendHello();
214
- if (isOfferer(this.options.selfUid, message.from)) {
314
+ if (offerer) {
215
315
  void this.initiateOffer(peer);
216
316
  }
217
317
  }
218
318
 
319
+ private handleState(message: SignalMessage): void {
320
+ const peer = this.peers.get(message.from);
321
+ if (peer) {
322
+ this.updatePeer(peer, undefined, message.state);
323
+ }
324
+ }
325
+
326
+ private handleReaction(message: SignalMessage): void {
327
+ const peer = this.peers.get(message.from);
328
+ if (peer && isReactionEmoji(message.emoji)) {
329
+ this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
330
+ }
331
+ }
332
+
333
+ /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
334
+ * hand going up) only when something actually changed. */
335
+ private updatePeer(peer: PeerState, name: string | undefined, state: ParticipantState | undefined): void {
336
+ const nextName = name ?? peer.name;
337
+ const next = state ? sanitizeState(state) : undefined;
338
+ const handWentUp = !!next && next.handRaised && !peer.handRaised;
339
+ if (nextName === peer.name && (!next || sameState(next, peer))) {
340
+ return;
341
+ }
342
+ peer.name = nextName;
343
+ if (next) {
344
+ peer.audioOn = next.audioOn;
345
+ peer.videoOn = next.videoOn;
346
+ peer.handRaised = next.handRaised;
347
+ }
348
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
349
+ if (handWentUp) {
350
+ this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
351
+ }
352
+ }
353
+
219
354
  private handleBye(uid: string): void {
355
+ this.orphanCandidates.delete(uid);
220
356
  const peer = this.peers.get(uid);
221
357
  if (!peer) {
222
358
  return;
@@ -230,23 +366,35 @@ export class MeshConnectionManager {
230
366
  }
231
367
  }
232
368
 
233
- private createPeer(uid: string, name: string): PeerState {
369
+ private createPeer(uid: string, name: string, state: ParticipantState | undefined, offerer: boolean): PeerState {
234
370
  const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
235
- const peer: PeerState = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
371
+ const peer: PeerState = {
372
+ uid,
373
+ name,
374
+ ...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
375
+ pc,
376
+ senders: offerer
377
+ ? {
378
+ audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
379
+ video: pc.addTransceiver("video", this.localTracks.video).sender,
380
+ }
381
+ : {},
382
+ remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
383
+ remoteDescriptionSet: false,
384
+ pendingCandidates: this.orphanCandidates.get(uid) ?? [],
385
+ };
386
+ this.orphanCandidates.delete(uid);
236
387
  this.peers.set(uid, peer);
237
- for (const track of this.options.localStream.getTracks()) {
238
- pc.addTrack(track, this.options.localStream);
239
- }
240
388
  pc.onicecandidate = (event) => {
241
389
  if (event.candidate) {
242
390
  this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
243
391
  }
244
392
  };
245
393
  pc.ontrack = (event) => {
246
- const stream = event.streams[0];
247
- if (stream) {
248
- this.emit({ type: "remote-stream", uid, stream });
394
+ if (!peer.remoteStream.getTracks().includes(event.track)) {
395
+ peer.remoteStream.addTrack(event.track);
249
396
  }
397
+ this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
250
398
  };
251
399
  pc.onconnectionstatechange = () => {
252
400
  if (pc.connectionState === "failed" || pc.connectionState === "closed") {
@@ -269,12 +417,23 @@ export class MeshConnectionManager {
269
417
  let peer = this.peers.get(message.from);
270
418
  const isNew = !peer;
271
419
  if (!peer) {
272
- peer = this.createPeer(message.from, message.from);
420
+ peer = this.createPeer(message.from, message.from, undefined, false);
273
421
  }
274
422
  await peer.pc.setRemoteDescription(message.sdp);
275
423
  this.flushPendingCandidates(peer);
424
+ if (!peer.senders.audio && !peer.senders.video) {
425
+ // The answerer's half of the media setup - see this module's doc comment.
426
+ peer.senders = peer.pc.claimTransceivers();
427
+ for (const kind of ["audio", "video"] as const) {
428
+ const track = this.localTracks[kind];
429
+ if (track) {
430
+ await peer.senders[kind]?.replaceTrack(track);
431
+ }
432
+ }
433
+ }
276
434
  if (isNew) {
277
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
435
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
436
+ this.sendHello();
278
437
  }
279
438
  const answer = await peer.pc.createAnswer();
280
439
  await peer.pc.setLocalDescription(answer);
@@ -296,10 +455,16 @@ export class MeshConnectionManager {
296
455
  }
297
456
  const peer = this.peers.get(message.from);
298
457
  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.
458
+ // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
459
+ // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
460
+ const held = this.orphanCandidates.get(message.from);
461
+ if (held) {
462
+ if (held.length < MAX_ORPHAN_CANDIDATES) {
463
+ held.push(message.candidate);
464
+ }
465
+ } else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
466
+ this.orphanCandidates.set(message.from, [message.candidate]);
467
+ }
303
468
  return;
304
469
  }
305
470
  if (!peer.remoteDescriptionSet) {
@@ -330,12 +495,12 @@ export class MeshConnectionManager {
330
495
  // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
331
496
  // - see this module's doc comment - so every participant converges on the same winner. When the
332
497
  // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
333
- // presenter is `this.options.selfUid` itself, whose own optimistic claim simply stays in place.
498
+ // presenter is this participant itself, whose own optimistic claim simply stays in place.
334
499
  if (from < this.currentPresenterUid) {
335
500
  const loser = this.currentPresenterUid;
336
501
  this.currentPresenterUid = from;
337
502
  this.emit({ type: "presenter-changed", uid: from });
338
- if (loser === this.options.selfUid) {
503
+ if (loser === this.selfId) {
339
504
  this.send({ kind: "presenter-release" });
340
505
  }
341
506
  }
@@ -348,3 +513,20 @@ export class MeshConnectionManager {
348
513
  }
349
514
  }
350
515
  }
516
+
517
+ function toParticipant(peer: PeerState): MeshParticipant {
518
+ return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
519
+ }
520
+
521
+ function sameState(a: ParticipantState, b: ParticipantState): boolean {
522
+ return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
523
+ }
524
+
525
+ /** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
526
+ function sanitizeState(state: ParticipantState): ParticipantState {
527
+ return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
528
+ }
529
+
530
+ function isReactionEmoji(emoji: unknown): emoji is (typeof REACTION_EMOJIS)[number] {
531
+ return (REACTION_EMOJIS as readonly unknown[]).includes(emoji);
532
+ }
@@ -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,41 @@
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 authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
49
+ * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
50
+ * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
51
+ * exactly that uid. */
28
52
  from: string;
53
+ /** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
54
+ * two participants, not one that ignores its own messages. A `peer` that doesn't start with `<from>~` is
55
+ * ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
56
+ * identified by `from`). */
57
+ peer?: string;
29
58
  /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
30
59
  to?: string;
31
60
  /** `hello` only - the display name the sender chose in the lobby. */
32
61
  name?: string;
62
+ /** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
63
+ state?: ParticipantState;
64
+ /** `reaction` only - one of `REACTION_EMOJIS`. */
65
+ emoji?: string;
33
66
  /** `offer`/`answer` only. */
34
67
  sdp?: RTCSessionDescriptionInit;
35
68
  /** `ice-candidate` only. */
@@ -47,9 +80,10 @@ export interface SignalingChannel {
47
80
  onMessage(handler: (message: SignalMessage) => void): () => void;
48
81
  }
49
82
 
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. */
83
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
84
+ * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
85
+ * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
86
+ * `RTCRtpSender` already satisfies this shape. */
53
87
  export interface RTCRtpSenderLike {
54
88
  track: MediaStreamTrack | null;
55
89
  replaceTrack(track: MediaStreamTrack | null): Promise<void>;
@@ -60,8 +94,14 @@ export interface RTCRtpSenderLike {
60
94
  * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
61
95
  * this stands in for and keeping a test fake to the bare minimum. */
62
96
  export interface RTCPeerConnectionLike {
63
- addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
64
- getSenders(): RTCRtpSenderLike[];
97
+ /** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
98
+ * nothing until `replaceTrack()` supplies it otherwise. */
99
+ addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): { sender: RTCRtpSenderLike };
100
+ /** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
101
+ * send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
102
+ * transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
103
+ * unused, and everything the answerer sends is silently lost.) */
104
+ claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
65
105
  createOffer(): Promise<RTCSessionDescriptionInit>;
66
106
  createAnswer(): Promise<RTCSessionDescriptionInit>;
67
107
  setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
@@ -69,7 +109,7 @@ export interface RTCPeerConnectionLike {
69
109
  addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
70
110
  close(): void;
71
111
  onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
72
- ontrack: ((event: { streams: readonly MediaStream[] }) => void) | null;
112
+ ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
73
113
  onconnectionstatechange: (() => void) | null;
74
114
  connectionState: string;
75
115
  }
@@ -78,13 +118,16 @@ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] })
78
118
 
79
119
  /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
80
120
  * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
81
- export interface MeshParticipant {
121
+ export interface MeshParticipant extends ParticipantState {
82
122
  uid: string;
83
123
  name: string;
84
124
  }
85
125
 
86
126
  export type MeshEvent =
87
127
  | { type: "participant-joined"; participant: MeshParticipant }
128
+ | { type: "participant-updated"; participant: MeshParticipant }
88
129
  | { type: "participant-left"; uid: string }
89
130
  | { type: "remote-stream"; uid: string; stream: MediaStream }
131
+ | { type: "hand-raised"; uid: string; name: string }
132
+ | { type: "reaction"; uid: string; name: string; emoji: string }
90
133
  | { type: "presenter-changed"; uid: string | undefined };