@rapidmx/meet-plugin 0.6.0 → 0.8.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 (131) hide show
  1. package/README.md +18 -5
  2. package/apps/meet/[token].tsx +11 -10
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +26 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetChrome.tsx +1 -1
  7. package/apps/meet/_MeetLobby.tsx +36 -4
  8. package/apps/meet/_ParticipantTile.tsx +73 -10
  9. package/apps/meet/_icons.tsx +4 -0
  10. package/apps/meet/_layout.tsx +2 -2
  11. package/apps/meet/_meetApi.ts +7 -1
  12. package/apps/meet/index.tsx +2 -2
  13. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
  14. package/apps/settings-video-conferencing/_layout.tsx +2 -2
  15. package/apps/settings-video-conferencing/index.tsx +3 -3
  16. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
  17. package/apps/shared/media/filters/accessories.ts +262 -0
  18. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  19. package/apps/shared/media/filters/filterTypes.ts +89 -0
  20. package/apps/shared/media/filters/mlModels.ts +174 -0
  21. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  22. package/apps/shared/media/mediaPreferences.ts +105 -0
  23. package/apps/shared/media/useLocalMedia.ts +198 -14
  24. package/apps/shared/push/GuestSignalingClient.ts +4 -4
  25. package/apps/shared/relay/AudioPlayer.ts +111 -0
  26. package/apps/shared/relay/AudioSender.ts +194 -0
  27. package/apps/shared/relay/RelayClient.ts +262 -0
  28. package/apps/shared/relay/RelayReceiver.ts +72 -0
  29. package/apps/shared/relay/RelaySender.ts +74 -0
  30. package/apps/shared/relay/RelayTransport.ts +188 -0
  31. package/apps/shared/relay/VideoPlayer.ts +110 -0
  32. package/apps/shared/relay/VideoSender.ts +216 -0
  33. package/apps/shared/relay/audioResume.ts +55 -0
  34. package/apps/shared/relay/frames.ts +275 -0
  35. package/apps/shared/relay/relayEnv.ts +230 -0
  36. package/apps/shared/relay/senderCommon.ts +30 -0
  37. package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
  38. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  39. package/apps/shared/webrtc/types.ts +32 -2
  40. package/dist/apps/meet/[token].d.ts +3 -3
  41. package/dist/apps/meet/[token].js +10 -10
  42. package/dist/apps/meet/_CallControls.d.ts +2 -1
  43. package/dist/apps/meet/_CallControls.js +6 -3
  44. package/dist/apps/meet/_CallView.d.ts +4 -1
  45. package/dist/apps/meet/_CallView.js +20 -3
  46. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  47. package/dist/apps/meet/_EffectsPanel.js +60 -0
  48. package/dist/apps/meet/_MeetChrome.d.ts +1 -1
  49. package/dist/apps/meet/_MeetLobby.d.ts +8 -0
  50. package/dist/apps/meet/_MeetLobby.js +15 -4
  51. package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
  52. package/dist/apps/meet/_ParticipantTile.js +27 -4
  53. package/dist/apps/meet/_icons.d.ts +1 -0
  54. package/dist/apps/meet/_icons.js +1 -0
  55. package/dist/apps/meet/_layout.d.ts +1 -1
  56. package/dist/apps/meet/_layout.js +1 -1
  57. package/dist/apps/meet/_meetApi.d.ts +6 -0
  58. package/dist/apps/meet/_meetApi.js +1 -1
  59. package/dist/apps/meet/index.js +2 -2
  60. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
  61. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
  62. package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
  63. package/dist/apps/settings-video-conferencing/_layout.js +1 -1
  64. package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
  65. package/dist/apps/settings-video-conferencing/index.js +3 -3
  66. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
  67. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
  68. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  69. package/dist/apps/shared/media/filters/accessories.js +211 -0
  70. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  71. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  72. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  73. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  74. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  75. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  76. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  77. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  78. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  79. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  80. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  81. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  82. package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
  83. package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
  84. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  85. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  86. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  87. package/dist/apps/shared/relay/AudioSender.js +172 -0
  88. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  89. package/dist/apps/shared/relay/RelayClient.js +236 -0
  90. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  91. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  92. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  93. package/dist/apps/shared/relay/RelaySender.js +54 -0
  94. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  95. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  96. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  97. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  98. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  99. package/dist/apps/shared/relay/VideoSender.js +204 -0
  100. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  101. package/dist/apps/shared/relay/audioResume.js +45 -0
  102. package/dist/apps/shared/relay/frames.d.ts +101 -0
  103. package/dist/apps/shared/relay/frames.js +221 -0
  104. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  105. package/dist/apps/shared/relay/relayEnv.js +69 -0
  106. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  107. package/dist/apps/shared/relay/senderCommon.js +10 -0
  108. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
  109. package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
  110. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  111. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  112. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  113. package/dist/lib/index.js +2 -0
  114. package/dist/lib/index.js.map +1 -1
  115. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  116. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  117. package/dist/lib/util/EffectsUtils.js +21 -0
  118. package/dist/lib/util/EffectsUtils.js.map +1 -0
  119. package/dist/lib/util/IceServerUtils.js +25 -4
  120. package/dist/lib/util/IceServerUtils.js.map +1 -1
  121. package/dist/lib/util/RedisRelayBus.js +241 -0
  122. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  123. package/dist/lib/util/RelayHub.js +405 -0
  124. package/dist/lib/util/RelayHub.js.map +1 -0
  125. package/dist/types/index.d.ts +2 -0
  126. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  127. package/dist/types/util/EffectsUtils.d.ts +10 -0
  128. package/dist/types/util/IceServerUtils.d.ts +12 -1
  129. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  130. package/dist/types/util/RelayHub.d.ts +188 -0
  131. package/package.json +25 -11
@@ -32,7 +32,7 @@
32
32
  * ## Join announcement and roster discovery
33
33
  *
34
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
35
+ * `@rapidmx/web-client`'s `lib/mail/pushClient.ts` doc comment for the identical guarantee on the mail push channel this
36
36
  * one shares its transport with), so a newcomer's `hello` would only reach participants who happened to already be
37
37
  * subscribed *and* who joined before them - never the other way around. Every participant who learns of a
38
38
  * genuinely new peer (from any message, not just `hello`) therefore echoes its own `hello` right back, once, the
@@ -61,14 +61,37 @@
61
61
  * `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
62
62
  *
63
63
  * Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
64
+ *
65
+ * ## Three media paths, tried in order
66
+ *
67
+ * Each pair of participants reaches for the best path that works, and `MeshParticipant.transport` says which one it
68
+ * got:
69
+ *
70
+ * 1. **Direct peer-to-peer** (`"p2p"`). The `RTCPeerConnection` is given the STUN servers and the TURN server
71
+ * together, and ICE ranks a direct pair above a relayed one, so this is what a pair gets whenever it can.
72
+ * 2. **Relayed through the TURN server** (`"turn"`). The same connection, on the relay candidate ICE falls back to
73
+ * when the direct pairs fail - a participant behind a symmetric NAT or a firewall that blocks direct UDP. Which of
74
+ * the two ICE picked is read from the selected candidate pair once connected (`RTCPeerConnectionLike.connectionType()`).
75
+ * 3. **Proxied by this server over a WebSocket** (`"websocket"`, `apps/shared/relay/`). For a participant whose
76
+ * network lets nothing but ordinary HTTPS out, neither of the above ever connects. A pair whose connection has not
77
+ * come up within `connectTimeoutMs`, has failed, or has stayed `disconnected` for `disconnectedGraceMs` gives up
78
+ * on WebRTC and switches to it, telling the other side with a `relay-fallback` message so both switch even when
79
+ * only one of them noticed. Nothing is renegotiated - the pair simply stops using the `RTCPeerConnection` - and the
80
+ * relay's stream replaces the one WebRTC would have filled (a `remote-stream` event again). When the relay is
81
+ * unavailable (turned off by the operator, or a browser without WebCodecs) the pair is marked `"failed"` and stays
82
+ * in the roster, rather than the participant vanishing with no explanation as it used to.
83
+ *
84
+ * A pair never moves back up: once on the relay it stays there for the rest of the call.
64
85
  */
65
86
  import {
87
+ type MediaTransport,
66
88
  type MeshEvent,
67
89
  type MeshParticipant,
68
90
  type ParticipantState,
69
91
  type RTCPeerConnectionFactory,
70
92
  type RTCPeerConnectionLike,
71
93
  type RTCRtpSenderLike,
94
+ type RelayTransportLike,
72
95
  type SignalMessage,
73
96
  type SignalingChannel,
74
97
  REACTION_EMOJIS,
@@ -84,6 +107,14 @@ export function isOfferer(selfId: string, peerId: string): boolean {
84
107
  const MAX_ORPHAN_PEERS = 32;
85
108
  const MAX_ORPHAN_CANDIDATES = 64;
86
109
 
110
+ /** How long a connection may stay in `connecting` before the pair gives up on WebRTC. ICE with an unreachable TURN
111
+ * server can take the better part of a minute to report `failed`; a person waiting on a call will not. It is still long
112
+ * enough for a slow TURN allocation and a few round trips of signaling. */
113
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
114
+
115
+ /** How long a connection may stay `disconnected` (ICE lost the path but may still recover) before giving up on it. */
116
+ export const DEFAULT_DISCONNECTED_GRACE_MS = 8_000;
117
+
87
118
  type MediaKind = "audio" | "video";
88
119
 
89
120
  interface PeerState extends MeshParticipant {
@@ -93,6 +124,10 @@ interface PeerState extends MeshParticipant {
93
124
  remoteStream: MediaStream;
94
125
  remoteDescriptionSet: boolean;
95
126
  pendingCandidates: RTCIceCandidateInit[];
127
+ /** Gives up on WebRTC when the connection is still `connecting` after `connectTimeoutMs`. */
128
+ connectTimer: ReturnType<typeof setTimeout> | undefined;
129
+ /** Gives up on WebRTC when the connection has stayed `disconnected` for `disconnectedGraceMs`. */
130
+ disconnectTimer: ReturnType<typeof setTimeout> | undefined;
96
131
  }
97
132
 
98
133
  export interface MeshConnectionManagerOptions {
@@ -114,6 +149,13 @@ export interface MeshConnectionManagerOptions {
114
149
  /** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
115
150
  * `new MediaStream()`; a test supplies a fake. */
116
151
  createMediaStream?: () => MediaStream;
152
+ /** The WebSocket media relay, the last-resort path - see this module's doc comment. Leave unset when the
153
+ * operator has turned it off; without one a pair WebRTC cannot connect is marked `"failed"`. */
154
+ relay?: RelayTransportLike;
155
+ /** Defaults to `DEFAULT_CONNECT_TIMEOUT_MS`. */
156
+ connectTimeoutMs?: number;
157
+ /** Defaults to `DEFAULT_DISCONNECTED_GRACE_MS`. */
158
+ disconnectedGraceMs?: number;
117
159
  }
118
160
 
119
161
  export class MeshConnectionManager {
@@ -137,6 +179,8 @@ export class MeshConnectionManager {
137
179
  handRaised: false,
138
180
  ...options.localState,
139
181
  };
182
+ options.relay?.setLocalTrack("audio", this.localTracks.audio);
183
+ options.relay?.setLocalTrack("video", this.localTracks.video);
140
184
  }
141
185
 
142
186
  get participants(): MeshParticipant[] {
@@ -177,11 +221,13 @@ export class MeshConnectionManager {
177
221
  this.unsubscribe?.();
178
222
  this.unsubscribe = undefined;
179
223
  for (const peer of this.peers.values()) {
224
+ this.clearTimers(peer);
180
225
  peer.pc.close();
181
226
  }
182
227
  this.peers.clear();
183
228
  this.orphanCandidates.clear();
184
229
  this.listeners.clear();
230
+ this.options.relay?.close();
185
231
  }
186
232
 
187
233
  /** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
@@ -191,6 +237,7 @@ export class MeshConnectionManager {
191
237
  for (const peer of this.peers.values()) {
192
238
  void peer.senders[kind]?.replaceTrack(track);
193
239
  }
240
+ this.options.relay?.setLocalTrack(kind, track);
194
241
  }
195
242
 
196
243
  /** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
@@ -295,6 +342,9 @@ export class MeshConnectionManager {
295
342
  case "reaction":
296
343
  this.handleReaction(message);
297
344
  return;
345
+ case "relay-fallback":
346
+ this.handleRelayFallback(message.from);
347
+ return;
298
348
  }
299
349
  }
300
350
 
@@ -357,8 +407,13 @@ export class MeshConnectionManager {
357
407
  if (!peer) {
358
408
  return;
359
409
  }
410
+ this.clearTimers(peer);
360
411
  peer.pc.close();
361
412
  this.peers.delete(uid);
413
+ if (peer.transport === "websocket") {
414
+ this.options.relay?.stopReceivingFrom(uid);
415
+ this.syncRelaySending();
416
+ }
362
417
  this.emit({ type: "participant-left", uid });
363
418
  if (this.currentPresenterUid === uid) {
364
419
  this.currentPresenterUid = undefined;
@@ -382,6 +437,9 @@ export class MeshConnectionManager {
382
437
  remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
383
438
  remoteDescriptionSet: false,
384
439
  pendingCandidates: this.orphanCandidates.get(uid) ?? [],
440
+ transport: "connecting",
441
+ connectTimer: undefined,
442
+ disconnectTimer: undefined,
385
443
  };
386
444
  this.orphanCandidates.delete(uid);
387
445
  this.peers.set(uid, peer);
@@ -396,14 +454,108 @@ export class MeshConnectionManager {
396
454
  }
397
455
  this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
398
456
  };
399
- pc.onconnectionstatechange = () => {
400
- if (pc.connectionState === "failed" || pc.connectionState === "closed") {
401
- this.handleBye(uid);
402
- }
403
- };
457
+ pc.onconnectionstatechange = () => this.handleConnectionState(peer);
458
+ // Cleared the moment the connection comes up, so this only ever fires for a pair still waiting on WebRTC.
459
+ peer.connectTimer = setTimeout(() => this.fallBack(peer), this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
404
460
  return peer;
405
461
  }
406
462
 
463
+ /** Reacts to the peer connection's state: notes which path a connected pair got, and gives up on WebRTC for a
464
+ * pair that failed or stays disconnected - see `fallBack()`. Ignores a pair that already left WebRTC. */
465
+ private handleConnectionState(peer: PeerState): void {
466
+ if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
467
+ return;
468
+ }
469
+ switch (peer.pc.connectionState) {
470
+ case "connected":
471
+ this.clearTimers(peer);
472
+ void this.detectTransport(peer);
473
+ return;
474
+ case "disconnected":
475
+ // ICE often recovers from this on its own, so it is only given up on after a grace period.
476
+ peer.disconnectTimer ??= setTimeout(() => this.fallBack(peer), this.options.disconnectedGraceMs ?? DEFAULT_DISCONNECTED_GRACE_MS);
477
+ return;
478
+ case "failed":
479
+ this.fallBack(peer);
480
+ return;
481
+ case "closed":
482
+ this.handleBye(peer.uid);
483
+ return;
484
+ }
485
+ }
486
+
487
+ /** Records whether a connected pair is direct or relayed through TURN, from the candidate pair ICE selected. */
488
+ private async detectTransport(peer: PeerState): Promise<void> {
489
+ let type: "p2p" | "turn" | "unknown";
490
+ try {
491
+ type = await peer.pc.connectionType();
492
+ } catch {
493
+ type = "unknown";
494
+ }
495
+ // The pair may have moved on (fallen back, left, or dropped again) while the stats were being read.
496
+ if (peer.transport !== "websocket" && this.peers.get(peer.uid) === peer && peer.pc.connectionState === "connected") {
497
+ this.setTransport(peer, type === "turn" ? "turn" : "p2p");
498
+ }
499
+ }
500
+
501
+ private setTransport(peer: PeerState, transport: MediaTransport): void {
502
+ if (peer.transport !== transport) {
503
+ peer.transport = transport;
504
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
505
+ }
506
+ }
507
+
508
+ private clearTimers(peer: PeerState): void {
509
+ clearTimeout(peer.connectTimer);
510
+ clearTimeout(peer.disconnectTimer);
511
+ peer.connectTimer = undefined;
512
+ peer.disconnectTimer = undefined;
513
+ }
514
+
515
+ /**
516
+ * Gives up on WebRTC for `peer` and moves the pair to the WebSocket relay (the third path - see this module's doc
517
+ * comment), or marks it `"failed"` when there is no relay to move to. `notify` tells the other side, which may not
518
+ * have noticed yet; a pair told by the other side does not tell it back.
519
+ */
520
+ private fallBack(peer: PeerState, notify = true): void {
521
+ if (peer.transport === "websocket" || this.peers.get(peer.uid) !== peer) {
522
+ return;
523
+ }
524
+ this.clearTimers(peer);
525
+ const relay = this.options.relay;
526
+ if (!relay?.supported) {
527
+ this.setTransport(peer, "failed");
528
+ return;
529
+ }
530
+ // Stop listening to the dead connection before closing it, so closing it cannot be mistaken for the peer leaving.
531
+ peer.pc.onconnectionstatechange = null;
532
+ peer.pc.onicecandidate = null;
533
+ peer.pc.ontrack = null;
534
+ peer.pc.close();
535
+ peer.transport = "websocket";
536
+ relay.receiveFrom(peer.uid, (stream) => {
537
+ peer.remoteStream = stream;
538
+ this.emit({ type: "remote-stream", uid: peer.uid, stream });
539
+ });
540
+ this.syncRelaySending();
541
+ this.emit({ type: "participant-updated", participant: toParticipant(peer) });
542
+ if (notify) {
543
+ this.send({ kind: "relay-fallback", to: peer.uid });
544
+ }
545
+ }
546
+
547
+ private handleRelayFallback(from: string): void {
548
+ const peer = this.peers.get(from);
549
+ if (peer) {
550
+ this.fallBack(peer, false);
551
+ }
552
+ }
553
+
554
+ /** The relay only encodes and uploads the local media while at least one pair is actually using it. */
555
+ private syncRelaySending(): void {
556
+ this.options.relay?.setSending([...this.peers.values()].some((peer) => peer.transport === "websocket"));
557
+ }
558
+
407
559
  private async initiateOffer(peer: PeerState): Promise<void> {
408
560
  const offer = await peer.pc.createOffer();
409
561
  await peer.pc.setLocalDescription(offer);
@@ -515,7 +667,7 @@ export class MeshConnectionManager {
515
667
  }
516
668
 
517
669
  function toParticipant(peer: PeerState): MeshParticipant {
518
- return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
670
+ return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised, transport: peer.transport };
519
671
  }
520
672
 
521
673
  function sameState(a: ParticipantState, b: ParticipantState): boolean {
@@ -9,6 +9,27 @@
9
9
  * exist at all - it would only throw if actually *called* outside a browser. */
10
10
  import type { RTCPeerConnectionFactory, RTCPeerConnectionLike } from "./types.js";
11
11
 
12
+ /**
13
+ * Whether the pair ICE settled on goes through a TURN relay. The selected pair is found the standard way (the
14
+ * `transport` report's `selectedCandidatePairId`) and, for a browser that does not fill that in, as the succeeded and
15
+ * nominated `candidate-pair`. A relay on *either* end means the media passes through a TURN server, so it counts as
16
+ * `"turn"`; `"unknown"` when no pair is reported at all.
17
+ */
18
+ export async function selectedConnectionType(pc: Pick<RTCPeerConnection, "getStats">): Promise<"p2p" | "turn" | "unknown"> {
19
+ const reports = new Map<string, Record<string, unknown>>();
20
+ (await pc.getStats()).forEach((report: Record<string, unknown>) => reports.set(String(report.id), report));
21
+ const transport = [...reports.values()].find((report) => report.type === "transport" && report.selectedCandidatePairId);
22
+ const pair =
23
+ (transport ? reports.get(String(transport.selectedCandidatePairId)) : undefined) ??
24
+ [...reports.values()].find((report) => report.type === "candidate-pair" && (report.selected || (report.nominated && report.state === "succeeded")));
25
+ if (!pair) {
26
+ return "unknown";
27
+ }
28
+ const local = reports.get(String(pair.localCandidateId));
29
+ const remote = reports.get(String(pair.remoteCandidateId));
30
+ return local?.candidateType === "relay" || remote?.candidateType === "relay" ? "turn" : "p2p";
31
+ }
32
+
12
33
  export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) => {
13
34
  const pc = new RTCPeerConnection(config);
14
35
  const like: RTCPeerConnectionLike = {
@@ -33,6 +54,7 @@ export const createBrowserPeerConnection: RTCPeerConnectionFactory = (config) =>
33
54
  setRemoteDescription: (description) => pc.setRemoteDescription(description),
34
55
  addIceCandidate: (candidate) => pc.addIceCandidate(candidate),
35
56
  close: () => pc.close(),
57
+ connectionType: () => selectedConnectionType(pc),
36
58
  onicecandidate: null,
37
59
  ontrack: null,
38
60
  onconnectionstatechange: null,
@@ -44,7 +44,8 @@ export interface SignalMessage {
44
44
  | "presenter-claim"
45
45
  | "presenter-release"
46
46
  | "state"
47
- | "reaction";
47
+ | "reaction"
48
+ | "relay-fallback";
48
49
  /** The sender's authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
49
50
  * the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
50
51
  * `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
@@ -55,7 +56,7 @@ export interface SignalMessage {
55
56
  * ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
56
57
  * identified by `from`). */
57
58
  peer?: string;
58
- /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
59
+ /** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`/`relay-fallback`). */
59
60
  to?: string;
60
61
  /** `hello` only - the display name the sender chose in the lobby. */
61
62
  name?: string;
@@ -112,6 +113,33 @@ export interface RTCPeerConnectionLike {
112
113
  ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
113
114
  onconnectionstatechange: (() => void) | null;
114
115
  connectionState: string;
116
+ /** Which path the connected pair is using - read from the selected candidate pair once `connectionState` is
117
+ * `connected`. `"unknown"` when the browser does not report one (treated as direct by the caller). */
118
+ connectionType(): Promise<"p2p" | "turn" | "unknown">;
119
+ }
120
+
121
+ /** Which of the three media paths a participant's audio and video currently take, in the order they are tried:
122
+ *
123
+ * - `"p2p"`: a direct peer-to-peer WebRTC connection, the preferred path.
124
+ * - `"turn"`: still WebRTC, but relayed through the TURN server, for a participant a direct path cannot reach.
125
+ * - `"websocket"`: not WebRTC at all - encoded frames proxied by this server over a WebSocket (see
126
+ * `apps/shared/relay/`), the last resort when neither of the above connects.
127
+ * - `"connecting"`: no path established yet. `"failed"`: every path this browser can try has failed. */
128
+ export type MediaTransport = "connecting" | "p2p" | "turn" | "websocket" | "failed";
129
+
130
+ /** What `MeshConnectionManager` needs from the WebSocket media relay (`apps/shared/relay/RelayTransport.ts` implements
131
+ * it) - declared here so the mesh, and its tests, depend on the shape alone. */
132
+ export interface RelayTransportLike {
133
+ /** `false` when this browser cannot do it (no WebSocket, no WebCodecs) - the mesh then never falls back to it. */
134
+ readonly supported: boolean;
135
+ /** Starts receiving `peerId`'s media. `onStream` is given the `MediaStream` that plays it. */
136
+ receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
137
+ stopReceivingFrom(peerId: string): void;
138
+ /** Starts or stops publishing the local tracks to the relay. */
139
+ setSending(active: boolean): void;
140
+ /** Same as `MeshConnectionManager.setLocalTrack()` - `null` sends nothing for that kind. */
141
+ setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
142
+ close(): void;
115
143
  }
116
144
 
117
145
  export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] }) => RTCPeerConnectionLike;
@@ -121,6 +149,8 @@ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] })
121
149
  export interface MeshParticipant extends ParticipantState {
122
150
  uid: string;
123
151
  name: string;
152
+ /** How this participant's media currently reaches the local one - see `MediaTransport`. */
153
+ transport: MediaTransport;
124
154
  }
125
155
 
126
156
  export type MeshEvent =
@@ -6,14 +6,14 @@
6
6
  * ## Session-based name prefill - NOT implemented, and why
7
7
  *
8
8
  * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
9
- * holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
10
- * plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
9
+ * holds a RapidMX session (investigating `@rapidmx/web-client`'s `lib/profileApi.ts`/`lib/session.ts` was part of
10
+ * this plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
11
11
  * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
12
12
  * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
13
13
  * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
14
14
  * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
15
15
  * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
16
- * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
16
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`web-client`)
17
17
  * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
18
18
  * phase's scope):
19
19
  *
@@ -11,14 +11,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  * ## Session-based name prefill - NOT implemented, and why
12
12
  *
13
13
  * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
14
- * holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
15
- * plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
14
+ * holds a RapidMX session (investigating `@rapidmx/web-client`'s `lib/profileApi.ts`/`lib/session.ts` was part of
15
+ * this plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
16
16
  * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
17
17
  * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
18
18
  * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
19
19
  * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
20
20
  * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
21
- * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
21
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`web-client`)
22
22
  * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
23
23
  * phase's scope):
24
24
  *
@@ -31,10 +31,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
31
31
  * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
32
32
  */
33
33
  import React, { useState } from "react";
34
- import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
35
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
36
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
34
+ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
35
+ import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
36
+ import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
37
+ import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
38
38
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
39
39
  import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
40
  import MeetLobby from "./_MeetLobby.js";
@@ -49,10 +49,10 @@ export default function MeetJoinPage({ params }) {
49
49
  * window instead (see `_CallView.tsx`). */
50
50
  function MeetJoinContent({ token, branding }) {
51
51
  const [phase, setPhase] = useState("loading");
52
- const [joinResult, setJoinResult] = useState(null);
53
52
  const [name, setName] = useState("");
54
53
  const [loadError, setLoadError] = useState(null);
55
- const media = useLocalMedia();
54
+ const [joinResult, setJoinResult] = useState(null);
55
+ const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
56
56
  const { release } = media;
57
57
  React.useEffect(() => {
58
58
  let cancelled = false;
@@ -93,7 +93,7 @@ function MeetJoinContent({ token, branding }) {
93
93
  // guest token to apply as a cookie at all, and `GuestSignalingClient` relies entirely on the
94
94
  // browser's own already-existing `jwt` session cookie instead (see its own doc comment and
95
95
  // `VideoMeetingJoinResult`'s doc comment in `_meetApi.ts`).
96
- token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, media: media, onLeave: handleLeave }));
96
+ token: joinResult.token, selfUid: joinResult.selfUid, selfName: name, meetingTitle: joinResult.meeting.title, iceServers: joinResult.iceServers, relayEnabled: joinResult.relayEnabled, media: media, onLeave: handleLeave }));
97
97
  }
98
98
  let content;
99
99
  if (phase === "loading") {
@@ -2,7 +2,8 @@
2
2
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
3
3
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
4
4
  * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
5
- * screen, reactions, raise hand, grid/focus and leave.
5
+ * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
6
+ * (`_EffectsPanel.tsx`) and lights up while any filter is on.
6
7
  *
7
8
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
8
9
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
@@ -7,15 +7,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
8
8
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
9
9
  * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
10
- * screen, reactions, raise hand, grid/focus and leave.
10
+ * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
11
+ * (`_EffectsPanel.tsx`) and lights up while any filter is on.
11
12
  *
12
13
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
14
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
14
15
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
15
16
  */
16
17
  import { useEffect, useRef, useState } from "react";
18
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
17
19
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
18
- import { ChevronUpIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
20
+ import EffectsPanel from "./_EffectsPanel.js";
21
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
19
22
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
20
23
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
21
24
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -53,7 +56,7 @@ export default function CallControls({ media, isPresenting, presentingElsewhereN
53
56
  function toggleMenu(menu) {
54
57
  setOpenMenu((prev) => (prev === menu ? null : menu));
55
58
  }
56
- return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
59
+ return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
57
60
  onReaction(emoji);
58
61
  setOpenMenu(null);
59
62
  }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: onLeave, children: _jsx(LeaveIcon, {}) })] }));
@@ -42,6 +42,9 @@ export interface CallViewProps {
42
42
  selfName: string;
43
43
  meetingTitle: string;
44
44
  iceServers: RTCIceServer[];
45
+ /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
46
+ * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
47
+ relayEnabled?: boolean;
45
48
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
46
49
  media: LocalMedia;
47
50
  onLeave: () => void;
@@ -58,4 +61,4 @@ export declare function computeMainUid(options: {
58
61
  * from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
59
62
  * `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
60
63
  export declare function newPeerId(selfUid: string): string;
61
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps): React.JSX.Element;
64
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }: CallViewProps): React.JSX.Element;
@@ -42,6 +42,7 @@ import { startLevelMeter } from "../shared/media/levelMeter.js";
42
42
  import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
43
43
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
44
44
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
45
+ import { createRelayTransport } from "../shared/relay/RelayTransport.js";
45
46
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
46
47
  import CallControls from "./_CallControls.js";
47
48
  import ParticipantTile from "./_ParticipantTile.js";
@@ -69,7 +70,7 @@ export function computeMainUid(options) {
69
70
  export function newPeerId(selfUid) {
70
71
  return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
71
72
  }
72
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }) {
73
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }) {
73
74
  const [peerId] = useState(() => newPeerId(selfUid));
74
75
  const [connectError, setConnectError] = useState(null);
75
76
  const [participants, setParticipants] = useState([]);
@@ -85,6 +86,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
85
86
  const [announcement, setAnnouncement] = useState("");
86
87
  const [audioBlocked, setAudioBlocked] = useState(false);
87
88
  const [audioNonce, setAudioNonce] = useState(0);
89
+ const [signalingReady, setSignalingReady] = useState(false);
88
90
  const managerRef = useRef(null);
89
91
  const screenStreamRef = useRef(null);
90
92
  const levelMetersRef = useRef({});
@@ -110,6 +112,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
110
112
  iceServers,
111
113
  channel: client,
112
114
  createPeerConnection: createBrowserPeerConnection,
115
+ relay: relayEnabled ? createRelay(channel, peerId) : undefined,
113
116
  localAudioTrack: media.audioTrack,
114
117
  localVideoTrack: media.videoTrack,
115
118
  localState: { audioOn: media.micOn, videoOn: media.cameraOn },
@@ -162,6 +165,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
162
165
  .then(() => {
163
166
  if (!cancelled) {
164
167
  manager.start();
168
+ setSignalingReady(true);
165
169
  }
166
170
  })
167
171
  .catch((err) => {
@@ -266,8 +270,15 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
266
270
  const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
267
271
  const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
268
272
  const alone = participants.length === 0;
269
- const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
270
- const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, className: className }));
273
+ // This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
274
+ // and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
275
+ // so someone already in the call is not told they are "connecting" each time a newcomer arrives.
276
+ const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
277
+ const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, status: p.transport === "connecting" ? "Awaiting connection…" : undefined, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
278
+ const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined,
279
+ // A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
280
+ // to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
281
+ mirrored: media.filters.background !== "image", className: className }));
271
282
  const mainParticipant = participants.find((p) => p.uid === mainUid);
272
283
  const thumbnails = participants.filter((p) => p.uid !== mainUid);
273
284
  let stage;
@@ -297,6 +308,12 @@ function RemoteAudio({ stream, onBlocked }) {
297
308
  }, [stream, onBlocked]);
298
309
  return _jsx("audio", { ref: audioRef, autoPlay: true, "data-testid": "remote-audio" });
299
310
  }
311
+ /** The WebSocket media relay for this call, or `undefined` when this browser cannot run it (no WebCodecs) - the
312
+ * mesh then never falls back to it, and says so by marking an unreachable participant `"failed"`. */
313
+ function createRelay(meetingUid, peerId) {
314
+ const relay = createRelayTransport({ meetingUid, peerId });
315
+ return relay.supported ? relay : undefined;
316
+ }
300
317
  function stopLevelMeter(ref, uid) {
301
318
  ref.current[uid]?.stop();
302
319
  delete ref.current[uid];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The picker for the video filters (`apps/shared/media/filters/`): a background, a colour effect and a face accessory,
3
+ * each one of a short list, chosen independently so they combine. Shared by the lobby (on the page's light theme) and
4
+ * the in-call control bar (on its dark one) - `tone` picks the colours, nothing else differs.
5
+ *
6
+ * The background can also be a picture from the participant's own device: choosing "Custom image" with none picked yet
7
+ * opens the file picker, and once one is picked "Change image" is offered beside the options. The picture is read and
8
+ * kept in the browser (`filters/backgroundImage.ts`) - it is never uploaded.
9
+ */
10
+ import React from "react";
11
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
12
+ export type EffectsTone = "light" | "dark";
13
+ export interface EffectsPanelProps {
14
+ media: LocalMedia;
15
+ tone: EffectsTone;
16
+ }
17
+ export default function EffectsPanel({ media, tone }: EffectsPanelProps): React.JSX.Element;