@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.
- package/README.md +18 -5
- package/apps/meet/[token].tsx +11 -10
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +26 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetChrome.tsx +1 -1
- package/apps/meet/_MeetLobby.tsx +36 -4
- package/apps/meet/_ParticipantTile.tsx +73 -10
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_layout.tsx +2 -2
- package/apps/meet/_meetApi.ts +7 -1
- package/apps/meet/index.tsx +2 -2
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
- package/apps/settings-video-conferencing/_layout.tsx +2 -2
- package/apps/settings-video-conferencing/index.tsx +3 -3
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
- package/apps/shared/media/filters/accessories.ts +262 -0
- package/apps/shared/media/filters/backgroundImage.ts +69 -0
- package/apps/shared/media/filters/filterTypes.ts +89 -0
- package/apps/shared/media/filters/mlModels.ts +174 -0
- package/apps/shared/media/filters/pixelEffects.ts +61 -0
- package/apps/shared/media/mediaPreferences.ts +105 -0
- package/apps/shared/media/useLocalMedia.ts +198 -14
- package/apps/shared/push/GuestSignalingClient.ts +4 -4
- package/apps/shared/relay/AudioPlayer.ts +111 -0
- package/apps/shared/relay/AudioSender.ts +194 -0
- package/apps/shared/relay/RelayClient.ts +262 -0
- package/apps/shared/relay/RelayReceiver.ts +72 -0
- package/apps/shared/relay/RelaySender.ts +74 -0
- package/apps/shared/relay/RelayTransport.ts +188 -0
- package/apps/shared/relay/VideoPlayer.ts +110 -0
- package/apps/shared/relay/VideoSender.ts +216 -0
- package/apps/shared/relay/audioResume.ts +55 -0
- package/apps/shared/relay/frames.ts +275 -0
- package/apps/shared/relay/relayEnv.ts +230 -0
- package/apps/shared/relay/senderCommon.ts +30 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].d.ts +3 -3
- package/dist/apps/meet/[token].js +10 -10
- package/dist/apps/meet/_CallControls.d.ts +2 -1
- package/dist/apps/meet/_CallControls.js +6 -3
- package/dist/apps/meet/_CallView.d.ts +4 -1
- package/dist/apps/meet/_CallView.js +20 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetChrome.d.ts +1 -1
- package/dist/apps/meet/_MeetLobby.d.ts +8 -0
- package/dist/apps/meet/_MeetLobby.js +15 -4
- package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
- package/dist/apps/meet/_ParticipantTile.js +27 -4
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_layout.d.ts +1 -1
- package/dist/apps/meet/_layout.js +1 -1
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/meet/_meetApi.js +1 -1
- package/dist/apps/meet/index.js +2 -2
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
- package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_layout.js +1 -1
- package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/index.js +3 -3
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
- package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
- package/dist/apps/shared/media/filters/accessories.js +211 -0
- package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
- package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
- package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
- package/dist/apps/shared/media/filters/filterTypes.js +63 -0
- package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
- package/dist/apps/shared/media/filters/mlModels.js +106 -0
- package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
- package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
- package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
- package/dist/apps/shared/media/mediaPreferences.js +93 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
- package/dist/apps/shared/media/useLocalMedia.js +160 -14
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
- package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
- package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
- package/dist/apps/shared/relay/AudioPlayer.js +103 -0
- package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
- package/dist/apps/shared/relay/AudioSender.js +172 -0
- package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
- package/dist/apps/shared/relay/RelayClient.js +236 -0
- package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
- package/dist/apps/shared/relay/RelayReceiver.js +59 -0
- package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
- package/dist/apps/shared/relay/RelaySender.js +54 -0
- package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
- package/dist/apps/shared/relay/RelayTransport.js +142 -0
- package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
- package/dist/apps/shared/relay/VideoPlayer.js +102 -0
- package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
- package/dist/apps/shared/relay/VideoSender.js +204 -0
- package/dist/apps/shared/relay/audioResume.d.ts +12 -0
- package/dist/apps/shared/relay/audioResume.js +45 -0
- package/dist/apps/shared/relay/frames.d.ts +101 -0
- package/dist/apps/shared/relay/frames.js +221 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
- package/dist/apps/shared/relay/relayEnv.js +69 -0
- package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
- package/dist/apps/shared/relay/senderCommon.js +10 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
- package/dist/apps/shared/webrtc/types.d.ts +29 -2
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/EffectsUtils.js +21 -0
- package/dist/lib/util/EffectsUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +25 -4
- package/dist/lib/util/IceServerUtils.js.map +1 -1
- package/dist/lib/util/RedisRelayBus.js +241 -0
- package/dist/lib/util/RedisRelayBus.js.map +1 -0
- package/dist/lib/util/RelayHub.js +405 -0
- package/dist/lib/util/RelayHub.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
- package/dist/types/util/EffectsUtils.d.ts +10 -0
- package/dist/types/util/IceServerUtils.d.ts +12 -1
- package/dist/types/util/RedisRelayBus.d.ts +131 -0
- package/dist/types/util/RelayHub.d.ts +188 -0
- 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/
|
|
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
|
-
|
|
401
|
-
|
|
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/
|
|
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`/`
|
|
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/
|
|
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`/`
|
|
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/
|
|
35
|
-
import { ApiRequestError } from "@rapidmx/
|
|
36
|
-
import Button from "@rapidmx/
|
|
37
|
-
import Alert from "@rapidmx/
|
|
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
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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;
|