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