@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
@@ -1,17 +1,95 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Full-mesh WebRTC connection management: one direct `RTCPeerConnection` per other participant, signaled entirely
7
+ * over the meeting's own push channel (`SignalingChannel` - see `types.ts`) as this plugin's Phase 2
8
+ * `.claude/NOTES.md` entry lays out. Pure state machine, no JSX/DOM rendering - `apps/meet/_CallView.tsx` owns one
9
+ * instance per call and mirrors its events into React state.
10
+ *
11
+ * ## Who calls whom
12
+ *
13
+ * On learning of another participant (via `hello`), a peer connection for that pair is created by whichever side
14
+ * has the lexicographically smaller uid (`isOfferer()`) - a well-known, deterministic, race-free rule for a mesh:
15
+ * both sides independently compute the same answer with no coordination round trip, and exactly one side ever
16
+ * calls `createOffer()` for a given pair.
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
+ *
32
+ * ## Join announcement and roster discovery
33
+ *
34
+ * The channel has no history (a push event published while a socket was down is never replayed - see
35
+ * `@rapidmx/react-shared`'s `pushClient.ts` doc comment for the identical guarantee on the mail push channel this
36
+ * one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
37
+ * subscribed *and* who joined before them - never the other way around. Every participant who learns of a
38
+ * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
39
+ * first time it becomes aware of that peer - so within one extra round trip everyone converges on the same full
40
+ * roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
41
+ * ever discovers) rather than a risk of runaway flooding.
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
+ *
54
+ * ## Presenter (single-writer screen share)
55
+ *
56
+ * `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
57
+ * A claim is applied optimistically and locally at once; every recipient (including the claimant) applies the
58
+ * *first* claim it sees. If two claims genuinely race (both sent before either claimant heard the other's), every
59
+ * participant converges on the same winner by resolving the collision deterministically: the lexicographically
60
+ * smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
61
+ * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
62
+ *
63
+ * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
+ */
65
+ import { REACTION_EMOJIS, } from "./types.js";
1
66
  /** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
2
- export function isOfferer(selfUid, peerUid) {
3
- return selfUid < peerUid;
67
+ export function isOfferer(selfId, peerId) {
68
+ return selfId < peerId;
4
69
  }
70
+ /** How many peers' worth of early ICE candidates are held, and how many candidates per peer - bounds the memory a
71
+ * misbehaving participant could make this hold for peers that never materialize. */
72
+ const MAX_ORPHAN_PEERS = 32;
73
+ const MAX_ORPHAN_CANDIDATES = 64;
5
74
  export class MeshConnectionManager {
6
75
  constructor(options) {
7
76
  this.options = options;
8
77
  this.peers = new Map();
9
78
  this.listeners = new Set();
79
+ this.orphanCandidates = new Map();
10
80
  this.started = false;
11
81
  this.stopped = false;
82
+ this.selfId = options.peerId ?? options.selfUid;
83
+ this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
84
+ this.localState = {
85
+ audioOn: !!options.localAudioTrack,
86
+ videoOn: !!options.localVideoTrack,
87
+ handRaised: false,
88
+ ...options.localState,
89
+ };
12
90
  }
13
91
  get participants() {
14
- return [...this.peers.values()].map((peer) => ({ uid: peer.uid, name: peer.name }));
92
+ return [...this.peers.values()].map((peer) => toParticipant(peer));
15
93
  }
16
94
  get presenterUid() {
17
95
  return this.currentPresenterUid;
@@ -30,9 +108,9 @@ export class MeshConnectionManager {
30
108
  this.sendHello();
31
109
  }
32
110
  /** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
33
- * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping `localStream`'s
34
- * own tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns that
35
- * stream's lifecycle, only attaches it. */
111
+ * or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
112
+ * tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
113
+ * lifecycle, only sends them. */
36
114
  stop() {
37
115
  if (this.stopped) {
38
116
  return;
@@ -47,33 +125,52 @@ export class MeshConnectionManager {
47
125
  peer.pc.close();
48
126
  }
49
127
  this.peers.clear();
128
+ this.orphanCandidates.clear();
50
129
  this.listeners.clear();
51
130
  }
52
- /** Swaps every peer connection's outgoing video track (presentation mode) - see this module's doc comment.
53
- * `track` is `null` to stop sending video at all (never used by this plugin's UI today, which always has a
54
- * camera-or-screen track to fall back to, but kept correct since `RTCRtpSender.replaceTrack()` itself allows
55
- * it). */
56
- replaceLocalVideoTrack(track) {
131
+ /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
132
+ * joins later - see this module's doc comment. */
133
+ setLocalTrack(kind, track) {
134
+ this.localTracks[kind] = track;
57
135
  for (const peer of this.peers.values()) {
58
- const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
59
- void sender?.replaceTrack(track);
136
+ void peer.senders[kind]?.replaceTrack(track);
137
+ }
138
+ }
139
+ /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
140
+ * actually changed. Before `start()` it only records the state, which `hello` then carries. */
141
+ setLocalState(partial) {
142
+ const next = { ...this.localState, ...partial };
143
+ if (sameState(next, this.localState)) {
144
+ return;
145
+ }
146
+ this.localState = next;
147
+ if (this.started && !this.stopped) {
148
+ this.send({ kind: "state", state: next });
60
149
  }
61
150
  }
151
+ /** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
152
+ sendReaction(emoji) {
153
+ if (!isReactionEmoji(emoji)) {
154
+ return false;
155
+ }
156
+ this.send({ kind: "reaction", emoji });
157
+ return true;
158
+ }
62
159
  /** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
63
160
  * else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
64
161
  * rather than let a claim silently do nothing. */
65
162
  claimPresenter() {
66
- if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.options.selfUid) {
163
+ if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.selfId) {
67
164
  return false;
68
165
  }
69
- this.currentPresenterUid = this.options.selfUid;
166
+ this.currentPresenterUid = this.selfId;
70
167
  this.send({ kind: "presenter-claim" });
71
168
  this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
72
169
  return true;
73
170
  }
74
171
  /** Releases presenter status - a no-op unless the local participant currently holds it. */
75
172
  releasePresenter() {
76
- if (this.currentPresenterUid !== this.options.selfUid) {
173
+ if (this.currentPresenterUid !== this.selfId) {
77
174
  return;
78
175
  }
79
176
  this.currentPresenterUid = undefined;
@@ -81,21 +178,29 @@ export class MeshConnectionManager {
81
178
  this.emit({ type: "presenter-changed", uid: undefined });
82
179
  }
83
180
  sendHello() {
84
- this.send({ kind: "hello", name: this.options.selfName });
181
+ this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
85
182
  }
86
183
  send(partial) {
87
- this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
184
+ this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.selfId, ...partial });
88
185
  }
89
186
  emit(event) {
90
187
  for (const listener of [...this.listeners]) {
91
188
  listener(event);
92
189
  }
93
190
  }
94
- handleMessage(message) {
95
- if (message.type !== "video-meeting-signal" || message.from === this.options.selfUid) {
191
+ handleMessage(raw) {
192
+ if (raw.type !== "video-meeting-signal") {
96
193
  return;
97
194
  }
98
- if (message.to !== undefined && message.to !== this.options.selfUid) {
195
+ // From here on `from` is the sender's peer id - see `SignalMessage.peer`.
196
+ if (raw.peer !== undefined && raw.peer !== raw.from && !raw.peer.startsWith(`${raw.from}~`)) {
197
+ return;
198
+ }
199
+ const message = { ...raw, from: raw.peer ?? raw.from };
200
+ if (message.from === this.selfId) {
201
+ return;
202
+ }
203
+ if (message.to !== undefined && message.to !== this.selfId) {
99
204
  return;
100
205
  }
101
206
  switch (message.kind) {
@@ -120,23 +225,65 @@ export class MeshConnectionManager {
120
225
  case "presenter-release":
121
226
  this.handlePresenterRelease(message.from);
122
227
  return;
228
+ case "state":
229
+ this.handleState(message);
230
+ return;
231
+ case "reaction":
232
+ this.handleReaction(message);
233
+ return;
123
234
  }
124
235
  }
125
236
  handleHello(message) {
126
- if (this.peers.has(message.from)) {
237
+ const known = this.peers.get(message.from);
238
+ if (known) {
239
+ // An offer that beat this hello created the peer under a placeholder name - fill in the real one.
240
+ this.updatePeer(known, message.name, message.state);
127
241
  return;
128
242
  }
129
- const name = message.name ?? message.from;
130
- const peer = this.createPeer(message.from, name);
131
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
243
+ const offerer = isOfferer(this.selfId, message.from);
244
+ const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
245
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
132
246
  // Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
133
247
  // doc comment on roster discovery.
134
248
  this.sendHello();
135
- if (isOfferer(this.options.selfUid, message.from)) {
249
+ if (offerer) {
136
250
  void this.initiateOffer(peer);
137
251
  }
138
252
  }
253
+ handleState(message) {
254
+ const peer = this.peers.get(message.from);
255
+ if (peer) {
256
+ this.updatePeer(peer, undefined, message.state);
257
+ }
258
+ }
259
+ handleReaction(message) {
260
+ const peer = this.peers.get(message.from);
261
+ if (peer && isReactionEmoji(message.emoji)) {
262
+ this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
263
+ }
264
+ }
265
+ /** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
266
+ * hand going up) only when something actually changed. */
267
+ updatePeer(peer, name, state) {
268
+ const nextName = name ?? peer.name;
269
+ const next = state ? sanitizeState(state) : undefined;
270
+ const handWentUp = !!next && next.handRaised && !peer.handRaised;
271
+ if (nextName === peer.name && (!next || sameState(next, peer))) {
272
+ return;
273
+ }
274
+ peer.name = nextName;
275
+ if (next) {
276
+ peer.audioOn = next.audioOn;
277
+ peer.videoOn = next.videoOn;
278
+ peer.handRaised = next.handRaised;
279
+ }
280
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
281
+ if (handWentUp) {
282
+ this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
283
+ }
284
+ }
139
285
  handleBye(uid) {
286
+ this.orphanCandidates.delete(uid);
140
287
  const peer = this.peers.get(uid);
141
288
  if (!peer) {
142
289
  return;
@@ -149,23 +296,35 @@ export class MeshConnectionManager {
149
296
  this.emit({ type: "presenter-changed", uid: undefined });
150
297
  }
151
298
  }
152
- createPeer(uid, name) {
299
+ createPeer(uid, name, state, offerer) {
153
300
  const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
154
- const peer = { uid, name, pc, remoteDescriptionSet: false, pendingCandidates: [] };
301
+ const peer = {
302
+ uid,
303
+ name,
304
+ ...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
305
+ pc,
306
+ senders: offerer
307
+ ? {
308
+ audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
309
+ video: pc.addTransceiver("video", this.localTracks.video).sender,
310
+ }
311
+ : {},
312
+ remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
313
+ remoteDescriptionSet: false,
314
+ pendingCandidates: this.orphanCandidates.get(uid) ?? [],
315
+ };
316
+ this.orphanCandidates.delete(uid);
155
317
  this.peers.set(uid, peer);
156
- for (const track of this.options.localStream.getTracks()) {
157
- pc.addTrack(track, this.options.localStream);
158
- }
159
318
  pc.onicecandidate = (event) => {
160
319
  if (event.candidate) {
161
320
  this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
162
321
  }
163
322
  };
164
323
  pc.ontrack = (event) => {
165
- const stream = event.streams[0];
166
- if (stream) {
167
- this.emit({ type: "remote-stream", uid, stream });
324
+ if (!peer.remoteStream.getTracks().includes(event.track)) {
325
+ peer.remoteStream.addTrack(event.track);
168
326
  }
327
+ this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
169
328
  };
170
329
  pc.onconnectionstatechange = () => {
171
330
  if (pc.connectionState === "failed" || pc.connectionState === "closed") {
@@ -186,12 +345,23 @@ export class MeshConnectionManager {
186
345
  let peer = this.peers.get(message.from);
187
346
  const isNew = !peer;
188
347
  if (!peer) {
189
- peer = this.createPeer(message.from, message.from);
348
+ peer = this.createPeer(message.from, message.from, undefined, false);
190
349
  }
191
350
  await peer.pc.setRemoteDescription(message.sdp);
192
351
  this.flushPendingCandidates(peer);
352
+ if (!peer.senders.audio && !peer.senders.video) {
353
+ // The answerer's half of the media setup - see this module's doc comment.
354
+ peer.senders = peer.pc.claimTransceivers();
355
+ for (const kind of ["audio", "video"]) {
356
+ const track = this.localTracks[kind];
357
+ if (track) {
358
+ await peer.senders[kind]?.replaceTrack(track);
359
+ }
360
+ }
361
+ }
193
362
  if (isNew) {
194
- this.emit({ type: "participant-joined", participant: { uid: peer.uid, name: peer.name } });
363
+ this.emit({ type: "participant-joined", participant: toParticipant(peer) });
364
+ this.sendHello();
195
365
  }
196
366
  const answer = await peer.pc.createAnswer();
197
367
  await peer.pc.setLocalDescription(answer);
@@ -211,10 +381,17 @@ export class MeshConnectionManager {
211
381
  }
212
382
  const peer = this.peers.get(message.from);
213
383
  if (!peer) {
214
- // No connection tracked for this peer yet (a candidate that raced ahead of its own offer/answer) -
215
- // nothing to buffer it against, and one lost trickle-ICE candidate does not by itself break a
216
- // connection (there are normally several). See this module's known-limitations note in the Phase 2
217
- // report.
384
+ // A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
385
+ // the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
386
+ const held = this.orphanCandidates.get(message.from);
387
+ if (held) {
388
+ if (held.length < MAX_ORPHAN_CANDIDATES) {
389
+ held.push(message.candidate);
390
+ }
391
+ }
392
+ else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
393
+ this.orphanCandidates.set(message.from, [message.candidate]);
394
+ }
218
395
  return;
219
396
  }
220
397
  if (!peer.remoteDescriptionSet) {
@@ -243,12 +420,12 @@ export class MeshConnectionManager {
243
420
  // Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
244
421
  // - see this module's doc comment - so every participant converges on the same winner. When the
245
422
  // already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
246
- // presenter is `this.options.selfUid` itself, whose own optimistic claim simply stays in place.
423
+ // presenter is this participant itself, whose own optimistic claim simply stays in place.
247
424
  if (from < this.currentPresenterUid) {
248
425
  const loser = this.currentPresenterUid;
249
426
  this.currentPresenterUid = from;
250
427
  this.emit({ type: "presenter-changed", uid: from });
251
- if (loser === this.options.selfUid) {
428
+ if (loser === this.selfId) {
252
429
  this.send({ kind: "presenter-release" });
253
430
  }
254
431
  }
@@ -260,3 +437,16 @@ export class MeshConnectionManager {
260
437
  }
261
438
  }
262
439
  }
440
+ function toParticipant(peer) {
441
+ return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
442
+ }
443
+ function sameState(a, b) {
444
+ return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
445
+ }
446
+ /** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
447
+ function sanitizeState(state) {
448
+ return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
449
+ }
450
+ function isReactionEmoji(emoji) {
451
+ return REACTION_EMOJIS.includes(emoji);
452
+ }
@@ -1,8 +1,21 @@
1
1
  export const createBrowserPeerConnection = (config) => {
2
2
  const pc = new RTCPeerConnection(config);
3
3
  const like = {
4
- addTrack: (track, stream) => pc.addTrack(track, stream),
5
- getSenders: () => pc.getSenders(),
4
+ addTransceiver: (kind, track) => {
5
+ const { sender } = pc.addTransceiver(track ?? kind, { direction: "sendrecv" });
6
+ return { sender };
7
+ },
8
+ claimTransceivers: () => {
9
+ const senders = {};
10
+ for (const transceiver of pc.getTransceivers()) {
11
+ const kind = transceiver.receiver.track.kind;
12
+ if (!senders[kind]) {
13
+ transceiver.direction = "sendrecv";
14
+ senders[kind] = transceiver.sender;
15
+ }
16
+ }
17
+ return senders;
18
+ },
6
19
  createOffer: () => pc.createOffer(),
7
20
  createAnswer: () => pc.createAnswer(),
8
21
  setLocalDescription: (description) => pc.setLocalDescription(description),
@@ -20,7 +33,7 @@ export const createBrowserPeerConnection = (config) => {
20
33
  // `like` - a level of indirection needed because `like`'s own handler properties are reassigned *after* this
21
34
  // adapter object is constructed and returned (see `MeshConnectionManager.createPeer()`).
22
35
  pc.onicecandidate = (event) => like.onicecandidate?.({ candidate: event.candidate ? event.candidate.toJSON() : null });
23
- pc.ontrack = (event) => like.ontrack?.({ streams: event.streams });
36
+ pc.ontrack = (event) => like.ontrack?.({ track: event.track });
24
37
  pc.onconnectionstatechange = () => like.onconnectionstatechange?.();
25
38
  return like;
26
39
  };
@@ -2,6 +2,18 @@
2
2
  * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
3
3
  * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
4
4
  * its own doc comment. */
5
+ /** What a participant is sending, and whether their hand is up. Announced in `hello` and re-sent as a `state`
6
+ * message on every change, so a tile can show a muted microphone, an avatar for a camera that is off, and a raised
7
+ * hand without inspecting the media itself (the far end of a `replaceTrack(null)` gives no dependable signal that
8
+ * the track stopped). */
9
+ export interface ParticipantState {
10
+ audioOn: boolean;
11
+ videoOn: boolean;
12
+ handRaised: boolean;
13
+ }
14
+ /** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
15
+ * arbitrary string from another participant is never rendered. */
16
+ export declare const REACTION_EMOJIS: readonly ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];
5
17
  /**
6
18
  * One WebRTC signaling message, published as the JSON body of `POST /push/:meetingUid` (`BasePushRoute.send()`)
7
19
  * and received back over `/push`'s WebSocket, exactly as `BaseVideoMeetingRoute`'s doc comment describes ("SDP
@@ -9,22 +21,32 @@
9
21
  * literal so a listener on the meeting's channel (which will also see other frame shapes if this server ever
10
22
  * grows other push consumers) can cheaply recognize and skip anything that isn't one of these.
11
23
  *
12
- * `hello`/`bye`/`presenter-claim`/`presenter-release` are broadcast (`to` left unset - every participant is
13
- * relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) - a recipient
14
- * that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to every
15
- * subscriber, sender included.
24
+ * `hello`/`bye`/`presenter-claim`/`presenter-release`/`state`/`reaction` are broadcast (`to` left unset - every
25
+ * participant is relevant); `offer`/`answer`/`ice-candidate` are point-to-point (`to` is the intended peer's uid) -
26
+ * a recipient that isn't `to` (when set) ignores the message, since a channel's pub/sub fans every message out to
27
+ * every subscriber, sender included.
16
28
  */
17
29
  export interface SignalMessage {
18
30
  type: "video-meeting-signal";
19
- kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release";
20
- /** The sender's uid - the real, already-authenticated caller's own uid when `join()` returned
21
- * `authenticated: true`, else the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted (either way, see
22
- * `VideoMeetingJoinResult.selfUid`). */
31
+ kind: "hello" | "bye" | "offer" | "answer" | "ice-candidate" | "presenter-claim" | "presenter-release" | "state" | "reaction";
32
+ /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
33
+ * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
34
+ * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
35
+ * exactly that uid. */
23
36
  from: string;
37
+ /** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
38
+ * two participants, not one that ignores its own messages. A `peer` that doesn't start with `<from>~` is
39
+ * ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
40
+ * identified by `from`). */
41
+ peer?: string;
24
42
  /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
25
43
  to?: string;
26
44
  /** `hello` only - the display name the sender chose in the lobby. */
27
45
  name?: string;
46
+ /** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
47
+ state?: ParticipantState;
48
+ /** `reaction` only - one of `REACTION_EMOJIS`. */
49
+ emoji?: string;
28
50
  /** `offer`/`answer` only. */
29
51
  sdp?: RTCSessionDescriptionInit;
30
52
  /** `ice-candidate` only. */
@@ -40,9 +62,10 @@ export interface SignalingChannel {
40
62
  * rather than relying on the transport to). Returns the function that stops listening. */
41
63
  onMessage(handler: (message: SignalMessage) => void): () => void;
42
64
  }
43
- /** The subset of `RTCRtpSender` `MeshConnectionManager.replaceLocalVideoTrack()` uses (presentation mode - see
44
- * this plugin's Phase 2 `.claude/NOTES.md` entry: sharing replaces the outgoing camera track rather than adding a
45
- * second video track, so no renegotiation is ever needed). A real `RTCRtpSender` already satisfies this shape. */
65
+ /** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
66
+ * one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
67
+ * switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
68
+ * `RTCRtpSender` already satisfies this shape. */
46
69
  export interface RTCRtpSenderLike {
47
70
  track: MediaStreamTrack | null;
48
71
  replaceTrack(track: MediaStreamTrack | null): Promise<void>;
@@ -52,8 +75,16 @@ export interface RTCRtpSenderLike {
52
75
  * properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
53
76
  * this stands in for and keeping a test fake to the bare minimum. */
54
77
  export interface RTCPeerConnectionLike {
55
- addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSenderLike;
56
- getSenders(): RTCRtpSenderLike[];
78
+ /** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
79
+ * nothing until `replaceTrack()` supplies it otherwise. */
80
+ addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): {
81
+ sender: RTCRtpSenderLike;
82
+ };
83
+ /** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
84
+ * send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
85
+ * transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
86
+ * unused, and everything the answerer sends is silently lost.) */
87
+ claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
57
88
  createOffer(): Promise<RTCSessionDescriptionInit>;
58
89
  createAnswer(): Promise<RTCSessionDescriptionInit>;
59
90
  setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
@@ -64,7 +95,7 @@ export interface RTCPeerConnectionLike {
64
95
  candidate: RTCIceCandidateInit | null;
65
96
  }) => void) | null;
66
97
  ontrack: ((event: {
67
- streams: readonly MediaStream[];
98
+ track: MediaStreamTrack;
68
99
  }) => void) | null;
69
100
  onconnectionstatechange: (() => void) | null;
70
101
  connectionState: string;
@@ -74,13 +105,16 @@ export type RTCPeerConnectionFactory = (config: {
74
105
  }) => RTCPeerConnectionLike;
75
106
  /** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
76
107
  * the local participant themselves (the UI already knows its own name/uid without asking the manager). */
77
- export interface MeshParticipant {
108
+ export interface MeshParticipant extends ParticipantState {
78
109
  uid: string;
79
110
  name: string;
80
111
  }
81
112
  export type MeshEvent = {
82
113
  type: "participant-joined";
83
114
  participant: MeshParticipant;
115
+ } | {
116
+ type: "participant-updated";
117
+ participant: MeshParticipant;
84
118
  } | {
85
119
  type: "participant-left";
86
120
  uid: string;
@@ -88,6 +122,15 @@ export type MeshEvent = {
88
122
  type: "remote-stream";
89
123
  uid: string;
90
124
  stream: MediaStream;
125
+ } | {
126
+ type: "hand-raised";
127
+ uid: string;
128
+ name: string;
129
+ } | {
130
+ type: "reaction";
131
+ uid: string;
132
+ name: string;
133
+ emoji: string;
91
134
  } | {
92
135
  type: "presenter-changed";
93
136
  uid: string | undefined;
@@ -6,4 +6,6 @@
6
6
  * kept in their own module so a test can depend on the shapes alone. `GuestSignalingClient` itself now also serves
7
7
  * an already-authenticated real caller (`VideoMeetingJoinResult.authenticated`), not only a synthetic guest - see
8
8
  * its own doc comment. */
9
- export {};
9
+ /** The emoji a participant can send during a call. A received `reaction` outside this list is ignored, so an
10
+ * arbitrary string from another participant is never rendered. */
11
+ export const REACTION_EMOJIS = ["👍", "👏", "❤️", "🎉", "😂", "😮", "😢", "👋"];