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