@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
|
@@ -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,50 +60,87 @@
|
|
|
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
|
-
export function isOfferer(
|
|
49
|
-
return
|
|
78
|
+
export function isOfferer(selfId: string, peerId: string): boolean {
|
|
79
|
+
return selfId < peerId;
|
|
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
|
}
|
|
59
97
|
|
|
60
98
|
export interface MeshConnectionManagerOptions {
|
|
99
|
+
/** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
|
|
61
100
|
selfUid: string;
|
|
101
|
+
/** This tab's identity in the call, `<selfUid>~<random>` - defaults to `selfUid`. Everything the manager keys on
|
|
102
|
+
* (the roster, who offers, who presents) is a peer id, so two tabs of one account are two participants. */
|
|
103
|
+
peerId?: string;
|
|
62
104
|
selfName: string;
|
|
63
105
|
iceServers: RTCIceServer[];
|
|
64
106
|
channel: SignalingChannel;
|
|
65
107
|
createPeerConnection: RTCPeerConnectionFactory;
|
|
66
|
-
/** The
|
|
67
|
-
|
|
108
|
+
/** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
|
|
109
|
+
* replaced later with `setLocalTrack()`. */
|
|
110
|
+
localAudioTrack?: MediaStreamTrack | null;
|
|
111
|
+
localVideoTrack?: MediaStreamTrack | null;
|
|
112
|
+
/** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
|
|
113
|
+
localState?: Partial<ParticipantState>;
|
|
114
|
+
/** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
|
|
115
|
+
* `new MediaStream()`; a test supplies a fake. */
|
|
116
|
+
createMediaStream?: () => MediaStream;
|
|
68
117
|
}
|
|
69
118
|
|
|
70
119
|
export class MeshConnectionManager {
|
|
71
120
|
private readonly peers = new Map<string, PeerState>();
|
|
72
121
|
private readonly listeners = new Set<(event: MeshEvent) => void>();
|
|
122
|
+
private readonly orphanCandidates = new Map<string, RTCIceCandidateInit[]>();
|
|
123
|
+
private readonly localTracks: Record<MediaKind, MediaStreamTrack | null>;
|
|
124
|
+
private localState: ParticipantState;
|
|
73
125
|
private unsubscribe: (() => void) | undefined;
|
|
74
126
|
private started = false;
|
|
75
127
|
private stopped = false;
|
|
76
128
|
private currentPresenterUid: string | undefined;
|
|
129
|
+
private readonly selfId: string;
|
|
77
130
|
|
|
78
|
-
constructor(private readonly options: MeshConnectionManagerOptions) {
|
|
131
|
+
constructor(private readonly options: MeshConnectionManagerOptions) {
|
|
132
|
+
this.selfId = options.peerId ?? options.selfUid;
|
|
133
|
+
this.localTracks = { audio: options.localAudioTrack ?? null, video: options.localVideoTrack ?? null };
|
|
134
|
+
this.localState = {
|
|
135
|
+
audioOn: !!options.localAudioTrack,
|
|
136
|
+
videoOn: !!options.localVideoTrack,
|
|
137
|
+
handRaised: false,
|
|
138
|
+
...options.localState,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
79
141
|
|
|
80
142
|
get participants(): MeshParticipant[] {
|
|
81
|
-
return [...this.peers.values()].map((peer) => (
|
|
143
|
+
return [...this.peers.values()].map((peer) => toParticipant(peer));
|
|
82
144
|
}
|
|
83
145
|
|
|
84
146
|
get presenterUid(): string | undefined {
|
|
@@ -101,9 +163,9 @@ export class MeshConnectionManager {
|
|
|
101
163
|
}
|
|
102
164
|
|
|
103
165
|
/** 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
|
-
*
|
|
166
|
+
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
|
|
167
|
+
* tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
|
|
168
|
+
* lifecycle, only sends them. */
|
|
107
169
|
stop(): void {
|
|
108
170
|
if (this.stopped) {
|
|
109
171
|
return;
|
|
@@ -118,28 +180,49 @@ export class MeshConnectionManager {
|
|
|
118
180
|
peer.pc.close();
|
|
119
181
|
}
|
|
120
182
|
this.peers.clear();
|
|
183
|
+
this.orphanCandidates.clear();
|
|
121
184
|
this.listeners.clear();
|
|
122
185
|
}
|
|
123
186
|
|
|
124
|
-
/**
|
|
125
|
-
*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
replaceLocalVideoTrack(track: MediaStreamTrack | null): void {
|
|
187
|
+
/** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
|
|
188
|
+
* joins later - see this module's doc comment. */
|
|
189
|
+
setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void {
|
|
190
|
+
this.localTracks[kind] = track;
|
|
129
191
|
for (const peer of this.peers.values()) {
|
|
130
|
-
|
|
131
|
-
void sender?.replaceTrack(track);
|
|
192
|
+
void peer.senders[kind]?.replaceTrack(track);
|
|
132
193
|
}
|
|
133
194
|
}
|
|
134
195
|
|
|
196
|
+
/** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
|
|
197
|
+
* actually changed. Before `start()` it only records the state, which `hello` then carries. */
|
|
198
|
+
setLocalState(partial: Partial<ParticipantState>): void {
|
|
199
|
+
const next = { ...this.localState, ...partial };
|
|
200
|
+
if (sameState(next, this.localState)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.localState = next;
|
|
204
|
+
if (this.started && !this.stopped) {
|
|
205
|
+
this.send({ kind: "state", state: next });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
|
|
210
|
+
sendReaction(emoji: string): boolean {
|
|
211
|
+
if (!isReactionEmoji(emoji)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
this.send({ kind: "reaction", emoji });
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
135
218
|
/** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
|
|
136
219
|
* else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
|
|
137
220
|
* rather than let a claim silently do nothing. */
|
|
138
221
|
claimPresenter(): boolean {
|
|
139
|
-
if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.
|
|
222
|
+
if (this.currentPresenterUid !== undefined && this.currentPresenterUid !== this.selfId) {
|
|
140
223
|
return false;
|
|
141
224
|
}
|
|
142
|
-
this.currentPresenterUid = this.
|
|
225
|
+
this.currentPresenterUid = this.selfId;
|
|
143
226
|
this.send({ kind: "presenter-claim" });
|
|
144
227
|
this.emit({ type: "presenter-changed", uid: this.currentPresenterUid });
|
|
145
228
|
return true;
|
|
@@ -147,7 +230,7 @@ export class MeshConnectionManager {
|
|
|
147
230
|
|
|
148
231
|
/** Releases presenter status - a no-op unless the local participant currently holds it. */
|
|
149
232
|
releasePresenter(): void {
|
|
150
|
-
if (this.currentPresenterUid !== this.
|
|
233
|
+
if (this.currentPresenterUid !== this.selfId) {
|
|
151
234
|
return;
|
|
152
235
|
}
|
|
153
236
|
this.currentPresenterUid = undefined;
|
|
@@ -156,11 +239,11 @@ export class MeshConnectionManager {
|
|
|
156
239
|
}
|
|
157
240
|
|
|
158
241
|
private sendHello(): void {
|
|
159
|
-
this.send({ kind: "hello", name: this.options.selfName });
|
|
242
|
+
this.send({ kind: "hello", name: this.options.selfName, state: this.localState });
|
|
160
243
|
}
|
|
161
244
|
|
|
162
245
|
private send(partial: Omit<SignalMessage, "type" | "from">): void {
|
|
163
|
-
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, ...partial });
|
|
246
|
+
this.options.channel.send({ type: "video-meeting-signal", from: this.options.selfUid, peer: this.selfId, ...partial });
|
|
164
247
|
}
|
|
165
248
|
|
|
166
249
|
private emit(event: MeshEvent): void {
|
|
@@ -169,11 +252,19 @@ export class MeshConnectionManager {
|
|
|
169
252
|
}
|
|
170
253
|
}
|
|
171
254
|
|
|
172
|
-
private handleMessage(
|
|
173
|
-
if (
|
|
255
|
+
private handleMessage(raw: SignalMessage): void {
|
|
256
|
+
if (raw.type !== "video-meeting-signal") {
|
|
174
257
|
return;
|
|
175
258
|
}
|
|
176
|
-
|
|
259
|
+
// From here on `from` is the sender's peer id - see `SignalMessage.peer`.
|
|
260
|
+
if (raw.peer !== undefined && raw.peer !== raw.from && !raw.peer.startsWith(`${raw.from}~`)) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const message: SignalMessage = { ...raw, from: raw.peer ?? raw.from };
|
|
264
|
+
if (message.from === this.selfId) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (message.to !== undefined && message.to !== this.selfId) {
|
|
177
268
|
return;
|
|
178
269
|
}
|
|
179
270
|
switch (message.kind) {
|
|
@@ -198,25 +289,70 @@ export class MeshConnectionManager {
|
|
|
198
289
|
case "presenter-release":
|
|
199
290
|
this.handlePresenterRelease(message.from);
|
|
200
291
|
return;
|
|
292
|
+
case "state":
|
|
293
|
+
this.handleState(message);
|
|
294
|
+
return;
|
|
295
|
+
case "reaction":
|
|
296
|
+
this.handleReaction(message);
|
|
297
|
+
return;
|
|
201
298
|
}
|
|
202
299
|
}
|
|
203
300
|
|
|
204
301
|
private handleHello(message: SignalMessage): void {
|
|
205
|
-
|
|
302
|
+
const known = this.peers.get(message.from);
|
|
303
|
+
if (known) {
|
|
304
|
+
// An offer that beat this hello created the peer under a placeholder name - fill in the real one.
|
|
305
|
+
this.updatePeer(known, message.name, message.state);
|
|
206
306
|
return;
|
|
207
307
|
}
|
|
208
|
-
const
|
|
209
|
-
const peer = this.createPeer(message.from, name);
|
|
210
|
-
this.emit({ type: "participant-joined", participant:
|
|
308
|
+
const offerer = isOfferer(this.selfId, message.from);
|
|
309
|
+
const peer = this.createPeer(message.from, message.name ?? message.from, message.state, offerer);
|
|
310
|
+
this.emit({ type: "participant-joined", participant: toParticipant(peer) });
|
|
211
311
|
// Let a newcomer who couldn't have seen our own original `hello` learn about us too - see this module's
|
|
212
312
|
// doc comment on roster discovery.
|
|
213
313
|
this.sendHello();
|
|
214
|
-
if (
|
|
314
|
+
if (offerer) {
|
|
215
315
|
void this.initiateOffer(peer);
|
|
216
316
|
}
|
|
217
317
|
}
|
|
218
318
|
|
|
319
|
+
private handleState(message: SignalMessage): void {
|
|
320
|
+
const peer = this.peers.get(message.from);
|
|
321
|
+
if (peer) {
|
|
322
|
+
this.updatePeer(peer, undefined, message.state);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private handleReaction(message: SignalMessage): void {
|
|
327
|
+
const peer = this.peers.get(message.from);
|
|
328
|
+
if (peer && isReactionEmoji(message.emoji)) {
|
|
329
|
+
this.emit({ type: "reaction", uid: peer.uid, name: peer.name, emoji: message.emoji });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
|
|
334
|
+
* hand going up) only when something actually changed. */
|
|
335
|
+
private updatePeer(peer: PeerState, name: string | undefined, state: ParticipantState | undefined): void {
|
|
336
|
+
const nextName = name ?? peer.name;
|
|
337
|
+
const next = state ? sanitizeState(state) : undefined;
|
|
338
|
+
const handWentUp = !!next && next.handRaised && !peer.handRaised;
|
|
339
|
+
if (nextName === peer.name && (!next || sameState(next, peer))) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
peer.name = nextName;
|
|
343
|
+
if (next) {
|
|
344
|
+
peer.audioOn = next.audioOn;
|
|
345
|
+
peer.videoOn = next.videoOn;
|
|
346
|
+
peer.handRaised = next.handRaised;
|
|
347
|
+
}
|
|
348
|
+
this.emit({ type: "participant-updated", participant: toParticipant(peer) });
|
|
349
|
+
if (handWentUp) {
|
|
350
|
+
this.emit({ type: "hand-raised", uid: peer.uid, name: peer.name });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
219
354
|
private handleBye(uid: string): void {
|
|
355
|
+
this.orphanCandidates.delete(uid);
|
|
220
356
|
const peer = this.peers.get(uid);
|
|
221
357
|
if (!peer) {
|
|
222
358
|
return;
|
|
@@ -230,23 +366,35 @@ export class MeshConnectionManager {
|
|
|
230
366
|
}
|
|
231
367
|
}
|
|
232
368
|
|
|
233
|
-
private createPeer(uid: string, name: string): PeerState {
|
|
369
|
+
private createPeer(uid: string, name: string, state: ParticipantState | undefined, offerer: boolean): PeerState {
|
|
234
370
|
const pc = this.options.createPeerConnection({ iceServers: this.options.iceServers });
|
|
235
|
-
const peer: PeerState = {
|
|
371
|
+
const peer: PeerState = {
|
|
372
|
+
uid,
|
|
373
|
+
name,
|
|
374
|
+
...(state ? sanitizeState(state) : { audioOn: false, videoOn: false, handRaised: false }),
|
|
375
|
+
pc,
|
|
376
|
+
senders: offerer
|
|
377
|
+
? {
|
|
378
|
+
audio: pc.addTransceiver("audio", this.localTracks.audio).sender,
|
|
379
|
+
video: pc.addTransceiver("video", this.localTracks.video).sender,
|
|
380
|
+
}
|
|
381
|
+
: {},
|
|
382
|
+
remoteStream: (this.options.createMediaStream ?? (() => new MediaStream()))(),
|
|
383
|
+
remoteDescriptionSet: false,
|
|
384
|
+
pendingCandidates: this.orphanCandidates.get(uid) ?? [],
|
|
385
|
+
};
|
|
386
|
+
this.orphanCandidates.delete(uid);
|
|
236
387
|
this.peers.set(uid, peer);
|
|
237
|
-
for (const track of this.options.localStream.getTracks()) {
|
|
238
|
-
pc.addTrack(track, this.options.localStream);
|
|
239
|
-
}
|
|
240
388
|
pc.onicecandidate = (event) => {
|
|
241
389
|
if (event.candidate) {
|
|
242
390
|
this.send({ kind: "ice-candidate", to: uid, candidate: event.candidate });
|
|
243
391
|
}
|
|
244
392
|
};
|
|
245
393
|
pc.ontrack = (event) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.emit({ type: "remote-stream", uid, stream });
|
|
394
|
+
if (!peer.remoteStream.getTracks().includes(event.track)) {
|
|
395
|
+
peer.remoteStream.addTrack(event.track);
|
|
249
396
|
}
|
|
397
|
+
this.emit({ type: "remote-stream", uid, stream: peer.remoteStream });
|
|
250
398
|
};
|
|
251
399
|
pc.onconnectionstatechange = () => {
|
|
252
400
|
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
|
@@ -269,12 +417,23 @@ export class MeshConnectionManager {
|
|
|
269
417
|
let peer = this.peers.get(message.from);
|
|
270
418
|
const isNew = !peer;
|
|
271
419
|
if (!peer) {
|
|
272
|
-
peer = this.createPeer(message.from, message.from);
|
|
420
|
+
peer = this.createPeer(message.from, message.from, undefined, false);
|
|
273
421
|
}
|
|
274
422
|
await peer.pc.setRemoteDescription(message.sdp);
|
|
275
423
|
this.flushPendingCandidates(peer);
|
|
424
|
+
if (!peer.senders.audio && !peer.senders.video) {
|
|
425
|
+
// The answerer's half of the media setup - see this module's doc comment.
|
|
426
|
+
peer.senders = peer.pc.claimTransceivers();
|
|
427
|
+
for (const kind of ["audio", "video"] as const) {
|
|
428
|
+
const track = this.localTracks[kind];
|
|
429
|
+
if (track) {
|
|
430
|
+
await peer.senders[kind]?.replaceTrack(track);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
276
434
|
if (isNew) {
|
|
277
|
-
this.emit({ type: "participant-joined", participant:
|
|
435
|
+
this.emit({ type: "participant-joined", participant: toParticipant(peer) });
|
|
436
|
+
this.sendHello();
|
|
278
437
|
}
|
|
279
438
|
const answer = await peer.pc.createAnswer();
|
|
280
439
|
await peer.pc.setLocalDescription(answer);
|
|
@@ -296,10 +455,16 @@ export class MeshConnectionManager {
|
|
|
296
455
|
}
|
|
297
456
|
const peer = this.peers.get(message.from);
|
|
298
457
|
if (!peer) {
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
458
|
+
// A candidate that raced ahead of its own offer (each message is a separate `POST`) - hold it for
|
|
459
|
+
// the peer to claim when it is created. Bounded, see `MAX_ORPHAN_PEERS`.
|
|
460
|
+
const held = this.orphanCandidates.get(message.from);
|
|
461
|
+
if (held) {
|
|
462
|
+
if (held.length < MAX_ORPHAN_CANDIDATES) {
|
|
463
|
+
held.push(message.candidate);
|
|
464
|
+
}
|
|
465
|
+
} else if (this.orphanCandidates.size < MAX_ORPHAN_PEERS) {
|
|
466
|
+
this.orphanCandidates.set(message.from, [message.candidate]);
|
|
467
|
+
}
|
|
303
468
|
return;
|
|
304
469
|
}
|
|
305
470
|
if (!peer.remoteDescriptionSet) {
|
|
@@ -330,12 +495,12 @@ export class MeshConnectionManager {
|
|
|
330
495
|
// Collision: a claim from someone other than the presenter we already recorded. Resolve deterministically
|
|
331
496
|
// - see this module's doc comment - so every participant converges on the same winner. When the
|
|
332
497
|
// already-recorded presenter has the smaller uid, it wins and nothing changes here - including when that
|
|
333
|
-
// presenter is
|
|
498
|
+
// presenter is this participant itself, whose own optimistic claim simply stays in place.
|
|
334
499
|
if (from < this.currentPresenterUid) {
|
|
335
500
|
const loser = this.currentPresenterUid;
|
|
336
501
|
this.currentPresenterUid = from;
|
|
337
502
|
this.emit({ type: "presenter-changed", uid: from });
|
|
338
|
-
if (loser === this.
|
|
503
|
+
if (loser === this.selfId) {
|
|
339
504
|
this.send({ kind: "presenter-release" });
|
|
340
505
|
}
|
|
341
506
|
}
|
|
@@ -348,3 +513,20 @@ export class MeshConnectionManager {
|
|
|
348
513
|
}
|
|
349
514
|
}
|
|
350
515
|
}
|
|
516
|
+
|
|
517
|
+
function toParticipant(peer: PeerState): MeshParticipant {
|
|
518
|
+
return { uid: peer.uid, name: peer.name, audioOn: peer.audioOn, videoOn: peer.videoOn, handRaised: peer.handRaised };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function sameState(a: ParticipantState, b: ParticipantState): boolean {
|
|
522
|
+
return a.audioOn === b.audioOn && a.videoOn === b.videoOn && a.handRaised === b.handRaised;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Copies only the three booleans out of a received state, coerced - a message off the wire is untrusted. */
|
|
526
|
+
function sanitizeState(state: ParticipantState): ParticipantState {
|
|
527
|
+
return { audioOn: state.audioOn === true, videoOn: state.videoOn === true, handRaised: state.handRaised === true };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isReactionEmoji(emoji: unknown): emoji is (typeof REACTION_EMOJIS)[number] {
|
|
531
|
+
return (REACTION_EMOJIS as readonly unknown[]).includes(emoji);
|
|
532
|
+
}
|
|
@@ -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,41 @@
|
|
|
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 authenticated uid - the real caller's own uid when `join()` returned `authenticated: true`, else
|
|
49
|
+
* the `guest:<random>` uid `BaseVideoMeetingRoute.join()` minted. The server refuses a published message whose
|
|
50
|
+
* `from` isn't the authenticated caller's uid (it stops one participant speaking as another), so this must be
|
|
51
|
+
* exactly that uid. */
|
|
28
52
|
from: string;
|
|
53
|
+
/** Which of the sender's tabs or devices this is: `<from>~<random>`. The same account joining from two devices is
|
|
54
|
+
* two participants, not one that ignores its own messages. A `peer` that doesn't start with `<from>~` is
|
|
55
|
+
* ignored, so it can't name someone else's tab. Absent from a sender that predates it (the participant is then
|
|
56
|
+
* identified by `from`). */
|
|
57
|
+
peer?: string;
|
|
29
58
|
/** Set only on a point-to-point message (`offer`/`answer`/`ice-candidate`). */
|
|
30
59
|
to?: string;
|
|
31
60
|
/** `hello` only - the display name the sender chose in the lobby. */
|
|
32
61
|
name?: string;
|
|
62
|
+
/** `hello` and `state` - what the sender is currently sending and whether their hand is up. */
|
|
63
|
+
state?: ParticipantState;
|
|
64
|
+
/** `reaction` only - one of `REACTION_EMOJIS`. */
|
|
65
|
+
emoji?: string;
|
|
33
66
|
/** `offer`/`answer` only. */
|
|
34
67
|
sdp?: RTCSessionDescriptionInit;
|
|
35
68
|
/** `ice-candidate` only. */
|
|
@@ -47,9 +80,10 @@ export interface SignalingChannel {
|
|
|
47
80
|
onMessage(handler: (message: SignalMessage) => void): () => void;
|
|
48
81
|
}
|
|
49
82
|
|
|
50
|
-
/** The subset of `RTCRtpSender` `MeshConnectionManager.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
83
|
+
/** The subset of `RTCRtpSender` `MeshConnectionManager.setLocalTrack()` uses. Every connection carries one audio and
|
|
84
|
+
* one video sender for its whole life (see `MeshConnectionManager`'s doc comment), so turning a camera on or off,
|
|
85
|
+
* switching a device or sharing a screen only ever replaces the track a sender sends - no renegotiation. A real
|
|
86
|
+
* `RTCRtpSender` already satisfies this shape. */
|
|
53
87
|
export interface RTCRtpSenderLike {
|
|
54
88
|
track: MediaStreamTrack | null;
|
|
55
89
|
replaceTrack(track: MediaStreamTrack | null): Promise<void>;
|
|
@@ -60,8 +94,14 @@ export interface RTCRtpSenderLike {
|
|
|
60
94
|
* properties (`RTCPeerConnection`'s own `on*` style) rather than `addEventListener`, matching the browser API
|
|
61
95
|
* this stands in for and keeping a test fake to the bare minimum. */
|
|
62
96
|
export interface RTCPeerConnectionLike {
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
/** The offerer's side: adds a send-and-receive transceiver for `kind`, sending `track` when there is one and
|
|
98
|
+
* nothing until `replaceTrack()` supplies it otherwise. */
|
|
99
|
+
addTransceiver(kind: "audio" | "video", track: MediaStreamTrack | null): { sender: RTCRtpSenderLike };
|
|
100
|
+
/** The answerer's side: once the remote offer is applied, turns the transceivers that offer created into
|
|
101
|
+
* send-and-receive ones and returns their senders by kind. (A browser only matches an offer's m-lines to
|
|
102
|
+
* transceivers it created for the offer itself - one the answerer added up front with `addTransceiver()` is left
|
|
103
|
+
* unused, and everything the answerer sends is silently lost.) */
|
|
104
|
+
claimTransceivers(): Partial<Record<"audio" | "video", RTCRtpSenderLike>>;
|
|
65
105
|
createOffer(): Promise<RTCSessionDescriptionInit>;
|
|
66
106
|
createAnswer(): Promise<RTCSessionDescriptionInit>;
|
|
67
107
|
setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
|
@@ -69,7 +109,7 @@ export interface RTCPeerConnectionLike {
|
|
|
69
109
|
addIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
|
70
110
|
close(): void;
|
|
71
111
|
onicecandidate: ((event: { candidate: RTCIceCandidateInit | null }) => void) | null;
|
|
72
|
-
ontrack: ((event: {
|
|
112
|
+
ontrack: ((event: { track: MediaStreamTrack }) => void) | null;
|
|
73
113
|
onconnectionstatechange: (() => void) | null;
|
|
74
114
|
connectionState: string;
|
|
75
115
|
}
|
|
@@ -78,13 +118,16 @@ export type RTCPeerConnectionFactory = (config: { iceServers: RTCIceServer[] })
|
|
|
78
118
|
|
|
79
119
|
/** One other participant currently known to be in the call - `MeshConnectionManager.participants` never includes
|
|
80
120
|
* the local participant themselves (the UI already knows its own name/uid without asking the manager). */
|
|
81
|
-
export interface MeshParticipant {
|
|
121
|
+
export interface MeshParticipant extends ParticipantState {
|
|
82
122
|
uid: string;
|
|
83
123
|
name: string;
|
|
84
124
|
}
|
|
85
125
|
|
|
86
126
|
export type MeshEvent =
|
|
87
127
|
| { type: "participant-joined"; participant: MeshParticipant }
|
|
128
|
+
| { type: "participant-updated"; participant: MeshParticipant }
|
|
88
129
|
| { type: "participant-left"; uid: string }
|
|
89
130
|
| { type: "remote-stream"; uid: string; stream: MediaStream }
|
|
131
|
+
| { type: "hand-raised"; uid: string; name: string }
|
|
132
|
+
| { type: "reaction"; uid: string; name: string; emoji: string }
|
|
90
133
|
| { type: "presenter-changed"; uid: string | undefined };
|