@voltkit-rtc/client 0.2.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/cjs/broadcast.d.ts +130 -0
- package/dist/cjs/broadcast.d.ts.map +1 -0
- package/dist/cjs/broadcast.js +320 -0
- package/dist/cjs/broadcast.js.map +1 -0
- package/dist/cjs/chat.d.ts +138 -0
- package/dist/cjs/chat.d.ts.map +1 -0
- package/dist/cjs/chat.js +267 -0
- package/dist/cjs/chat.js.map +1 -0
- package/dist/cjs/index.d.ts +285 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +798 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/music.d.ts +76 -0
- package/dist/cjs/music.d.ts.map +1 -0
- package/dist/cjs/music.js +176 -0
- package/dist/cjs/music.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/broadcast.d.ts +130 -0
- package/dist/esm/broadcast.d.ts.map +1 -0
- package/dist/esm/broadcast.js +313 -0
- package/dist/esm/broadcast.js.map +1 -0
- package/dist/esm/chat.d.ts +138 -0
- package/dist/esm/chat.d.ts.map +1 -0
- package/dist/esm/chat.js +262 -0
- package/dist/esm/chat.js.map +1 -0
- package/dist/esm/index.d.ts +285 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +785 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/music.d.ts +76 -0
- package/dist/esm/music.d.ts.map +1 -0
- package/dist/esm/music.js +171 -0
- package/dist/esm/music.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/package.json +60 -0
- package/src/broadcast.ts +352 -0
- package/src/chat.ts +352 -0
- package/src/index.ts +945 -0
- package/src/music.ts +200 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client SDK over the orchestrator's signaling protocol.
|
|
3
|
+
*
|
|
4
|
+
* The point of shipping this is that consuming projects never touch
|
|
5
|
+
* mediasoup-client directly -- otherwise every project re-implements transport
|
|
6
|
+
* setup, consumer resume ordering and reconnection, each slightly wrong.
|
|
7
|
+
*/
|
|
8
|
+
import { Device } from 'mediasoup-client';
|
|
9
|
+
import { MusicSource } from './music.js';
|
|
10
|
+
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
11
|
+
export { AudioMixer, MusicSource } from './music.js';
|
|
12
|
+
export { ChatClient, ChatRpcError } from './chat.js';
|
|
13
|
+
const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
|
|
14
|
+
/** An error returned by the server, carrying its machine-readable code. */
|
|
15
|
+
export class RpcError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(`${code}: ${message}`);
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.name = 'RpcError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Failures that reconnecting cannot fix. A room that has ended is not coming
|
|
25
|
+
* back, and a token that is wrong will be wrong next time too -- retrying
|
|
26
|
+
* these just floods the log and burns the attempt budget that a genuine
|
|
27
|
+
* network blip needs.
|
|
28
|
+
*/
|
|
29
|
+
const TERMINAL_JOIN_ERRORS = new Set([
|
|
30
|
+
'no-room',
|
|
31
|
+
'room-ended',
|
|
32
|
+
'wrong-tenant',
|
|
33
|
+
'bad-token',
|
|
34
|
+
'no-token',
|
|
35
|
+
'forbidden',
|
|
36
|
+
// The account cannot use the service until it is topped up or restored.
|
|
37
|
+
'quota-exhausted',
|
|
38
|
+
'daily-limit-reached',
|
|
39
|
+
'monthly-limit-reached',
|
|
40
|
+
'tenant-suspended',
|
|
41
|
+
]);
|
|
42
|
+
const RPC_TIMEOUT_MS = 15_000;
|
|
43
|
+
const MAX_RECONNECT_ATTEMPTS = 8;
|
|
44
|
+
/** Server close code meaning "another socket took over this identity". */
|
|
45
|
+
const CLOSE_REPLACED = 4001;
|
|
46
|
+
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
47
|
+
const CLOSE_ENDED = 4003;
|
|
48
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
export class StreamClient {
|
|
50
|
+
wsUrl;
|
|
51
|
+
ws = null;
|
|
52
|
+
device = null;
|
|
53
|
+
sendTransport = null;
|
|
54
|
+
recvTransport = null;
|
|
55
|
+
// In-flight creations. Two `newProducer` events arriving back to back (a peer
|
|
56
|
+
// publishing audio and video) both call ensureRecvTransport before either
|
|
57
|
+
// await resolves, so a plain null check creates two transports -- and the
|
|
58
|
+
// server only remembers the last one, rejecting consumes on the first.
|
|
59
|
+
sendTransportPending = null;
|
|
60
|
+
recvTransportPending = null;
|
|
61
|
+
pending = new Map();
|
|
62
|
+
listeners = new Map();
|
|
63
|
+
consumers = new Map();
|
|
64
|
+
producers = new Map();
|
|
65
|
+
nextId = 1;
|
|
66
|
+
token;
|
|
67
|
+
joinResult = null;
|
|
68
|
+
localStream = null;
|
|
69
|
+
// A reconnect tears down every transport and producer. Without remembering
|
|
70
|
+
// that this client was publishing -- and what it was publishing with -- a
|
|
71
|
+
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
72
|
+
// disconnected: the room looks healthy and shows nothing.
|
|
73
|
+
wasPublishing = false;
|
|
74
|
+
publishConstraints = null;
|
|
75
|
+
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
76
|
+
music = null;
|
|
77
|
+
musicProducerId = null;
|
|
78
|
+
/** What each remote producer carries, so a pause can say "music" rather than just "audio". */
|
|
79
|
+
remoteSources = new Map();
|
|
80
|
+
closedByUs = false;
|
|
81
|
+
reconnecting = false;
|
|
82
|
+
reconnectAttempt = 0;
|
|
83
|
+
constructor(wsUrl, token) {
|
|
84
|
+
this.wsUrl = wsUrl;
|
|
85
|
+
this.token = token;
|
|
86
|
+
}
|
|
87
|
+
on(event, fn) {
|
|
88
|
+
let set = this.listeners.get(event);
|
|
89
|
+
if (!set) {
|
|
90
|
+
set = new Set();
|
|
91
|
+
this.listeners.set(event, set);
|
|
92
|
+
}
|
|
93
|
+
set.add(fn);
|
|
94
|
+
return () => set.delete(fn);
|
|
95
|
+
}
|
|
96
|
+
emit(event, ...args) {
|
|
97
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
98
|
+
try {
|
|
99
|
+
fn(...args);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error(`listener for ${event} threw`, err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ------------------------------------------------------------- connection
|
|
107
|
+
async connect() {
|
|
108
|
+
await this.openSocket();
|
|
109
|
+
const result = (await this.rpc('join', { token: this.token }));
|
|
110
|
+
this.joinResult = result;
|
|
111
|
+
this.device = new Device();
|
|
112
|
+
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
113
|
+
this.emit('joined', result);
|
|
114
|
+
// Subscribe to everything already publishing before we announce readiness.
|
|
115
|
+
for (const p of result.producers) {
|
|
116
|
+
await this.consume(p).catch((err) => this.emit('error', err));
|
|
117
|
+
}
|
|
118
|
+
// Restore our own broadcast. This is what makes an origin failover
|
|
119
|
+
// survivable for the host: it reconnects onto the new origin and the
|
|
120
|
+
// stream comes back on its own.
|
|
121
|
+
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
122
|
+
await this.republish().catch((err) => this.emit('error', err));
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
openSocket() {
|
|
127
|
+
// Abandon any previous socket first. Its close handler checks identity
|
|
128
|
+
// against `this.ws`, so once it is detached it can no longer trigger a
|
|
129
|
+
// reconnect -- which is what let multiple reconnect chains pile up.
|
|
130
|
+
this.detachSocket();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const url = new URL(this.wsUrl);
|
|
133
|
+
url.searchParams.set('token', this.token);
|
|
134
|
+
const ws = new WebSocket(url.toString());
|
|
135
|
+
this.ws = ws;
|
|
136
|
+
const onOpen = () => {
|
|
137
|
+
this.reconnectAttempt = 0;
|
|
138
|
+
this.emit('connected');
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
const onError = () => reject(new Error('websocket failed to open'));
|
|
142
|
+
ws.addEventListener('open', onOpen, { once: true });
|
|
143
|
+
ws.addEventListener('error', onError, { once: true });
|
|
144
|
+
ws.addEventListener('message', (ev) => {
|
|
145
|
+
if (this.ws === ws)
|
|
146
|
+
this.onMessage(ev.data);
|
|
147
|
+
});
|
|
148
|
+
ws.addEventListener('close', (ev) => {
|
|
149
|
+
ws.removeEventListener('error', onError);
|
|
150
|
+
// Only the socket we currently consider ours may drive reconnection.
|
|
151
|
+
if (this.ws !== ws)
|
|
152
|
+
return;
|
|
153
|
+
this.ws = null;
|
|
154
|
+
this.failAllPending(new Error(`socket closed (${ev.code})`));
|
|
155
|
+
this.emit('disconnected', ev.reason || String(ev.code));
|
|
156
|
+
if (this.closedByUs)
|
|
157
|
+
return;
|
|
158
|
+
if (ev.code === CLOSE_REPLACED) {
|
|
159
|
+
// Another session for this identity is live. Reconnecting would
|
|
160
|
+
// displace it, and it would displace us right back, forever.
|
|
161
|
+
this.emit('error', new Error('this identity connected from somewhere else'));
|
|
162
|
+
this.closedByUs = true;
|
|
163
|
+
this.emit('closed', 'replaced');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (ev.code === CLOSE_ENDED) {
|
|
167
|
+
// Normally roomClosed has already ended the session; this covers a
|
|
168
|
+
// close that arrived without it.
|
|
169
|
+
this.closedByUs = true;
|
|
170
|
+
this.emit('closed', 'room-closed');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void this.scheduleReconnect();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
detachSocket() {
|
|
178
|
+
const ws = this.ws;
|
|
179
|
+
this.ws = null;
|
|
180
|
+
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
181
|
+
try {
|
|
182
|
+
ws.close(1000, 'superseded');
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* already closing */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
191
|
+
* promotions move you to a different node. Always re-ask the orchestrator
|
|
192
|
+
* where to connect -- never assume the same node.
|
|
193
|
+
*/
|
|
194
|
+
async scheduleReconnect() {
|
|
195
|
+
// Single-flight. The previous version recursed from its own catch block
|
|
196
|
+
// while the failed socket's close handler started a second chain, so each
|
|
197
|
+
// failure doubled the number of chains until the server was being hammered.
|
|
198
|
+
if (this.reconnecting || this.closedByUs)
|
|
199
|
+
return;
|
|
200
|
+
this.reconnecting = true;
|
|
201
|
+
try {
|
|
202
|
+
while (!this.closedByUs) {
|
|
203
|
+
this.reconnectAttempt += 1;
|
|
204
|
+
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
205
|
+
this.emit('error', new Error(`gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
206
|
+
this.closedByUs = true;
|
|
207
|
+
this.emit('closed', 'gave-up');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
211
|
+
await sleep(backoff + Math.random() * 400);
|
|
212
|
+
if (this.closedByUs)
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
this.teardownMedia();
|
|
216
|
+
await this.connect();
|
|
217
|
+
return; // connect() resets the attempt counter once the socket opens
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
this.emit('error', err);
|
|
221
|
+
// Stop on failures a retry cannot resolve. Hammering a room that has
|
|
222
|
+
// ended produces one error per attempt forever and drowns out
|
|
223
|
+
// whatever actually went wrong.
|
|
224
|
+
if (err instanceof RpcError && TERMINAL_JOIN_ERRORS.has(err.code)) {
|
|
225
|
+
this.closedByUs = true;
|
|
226
|
+
this.emit('closed', 'gave-up');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
this.reconnecting = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
close() {
|
|
237
|
+
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
238
|
+
// others have already reported themselves and set closedByUs.
|
|
239
|
+
const wasOpen = !this.closedByUs;
|
|
240
|
+
this.closedByUs = true;
|
|
241
|
+
this.wasPublishing = false;
|
|
242
|
+
this.teardownMedia();
|
|
243
|
+
// Producers no longer stop their tracks, so releasing the camera and
|
|
244
|
+
// microphone is ours to do -- otherwise the device light stays on.
|
|
245
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
246
|
+
this.localStream = null;
|
|
247
|
+
const ws = this.ws;
|
|
248
|
+
this.ws = null;
|
|
249
|
+
ws?.close(1000, 'client closed');
|
|
250
|
+
if (wasOpen)
|
|
251
|
+
this.emit('closed', 'left');
|
|
252
|
+
}
|
|
253
|
+
teardownMedia() {
|
|
254
|
+
for (const c of this.consumers.values())
|
|
255
|
+
c.close();
|
|
256
|
+
this.consumers.clear();
|
|
257
|
+
for (const p of this.producers.values())
|
|
258
|
+
p.close();
|
|
259
|
+
this.producers.clear();
|
|
260
|
+
this.musicProducerId = null;
|
|
261
|
+
this.sendTransport?.close();
|
|
262
|
+
this.recvTransport?.close();
|
|
263
|
+
this.sendTransport = null;
|
|
264
|
+
this.recvTransport = null;
|
|
265
|
+
this.sendTransportPending = null;
|
|
266
|
+
this.recvTransportPending = null;
|
|
267
|
+
this.device = null;
|
|
268
|
+
}
|
|
269
|
+
/** Everything currently known about the room, for rendering. */
|
|
270
|
+
get info() {
|
|
271
|
+
return this.joinResult;
|
|
272
|
+
}
|
|
273
|
+
// -------------------------------------------------------------------- rpc
|
|
274
|
+
rpc(method, params = {}) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
277
|
+
reject(new Error('socket is not open'));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const id = this.nextId++;
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
this.pending.delete(id);
|
|
283
|
+
reject(new Error(`${method} timed out`));
|
|
284
|
+
}, RPC_TIMEOUT_MS);
|
|
285
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
286
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
failAllPending(err) {
|
|
290
|
+
for (const p of this.pending.values()) {
|
|
291
|
+
clearTimeout(p.timer);
|
|
292
|
+
p.reject(err);
|
|
293
|
+
}
|
|
294
|
+
this.pending.clear();
|
|
295
|
+
}
|
|
296
|
+
onMessage(raw) {
|
|
297
|
+
let msg;
|
|
298
|
+
try {
|
|
299
|
+
msg = JSON.parse(raw);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof msg.id === 'number') {
|
|
305
|
+
const p = this.pending.get(msg.id);
|
|
306
|
+
if (!p)
|
|
307
|
+
return;
|
|
308
|
+
clearTimeout(p.timer);
|
|
309
|
+
this.pending.delete(msg.id);
|
|
310
|
+
if (msg.ok)
|
|
311
|
+
p.resolve(msg.result);
|
|
312
|
+
else {
|
|
313
|
+
const e = msg.error;
|
|
314
|
+
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (typeof msg.event === 'string')
|
|
319
|
+
void this.onEvent(msg.event, msg.data);
|
|
320
|
+
}
|
|
321
|
+
async onEvent(event, data) {
|
|
322
|
+
const d = (data ?? {});
|
|
323
|
+
switch (event) {
|
|
324
|
+
case 'newProducer':
|
|
325
|
+
await this.consume(data).catch((err) => this.emit('error', err));
|
|
326
|
+
break;
|
|
327
|
+
case 'producerClosed': {
|
|
328
|
+
const { producerId, identity } = data;
|
|
329
|
+
const consumer = this.consumers.get(producerId);
|
|
330
|
+
if (!consumer)
|
|
331
|
+
break; // never consumed it, or already cleaned up
|
|
332
|
+
consumer.close();
|
|
333
|
+
this.consumers.delete(producerId);
|
|
334
|
+
this.remoteSources.delete(producerId);
|
|
335
|
+
this.emit('trackEnded', { producerId, identity });
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case 'producerPaused': {
|
|
339
|
+
const p = data;
|
|
340
|
+
this.emit('trackMuted', {
|
|
341
|
+
identity: p.identity,
|
|
342
|
+
kind: p.kind,
|
|
343
|
+
source: this.remoteSources.get(p.producerId) ?? defaultSource(p.kind),
|
|
344
|
+
producerId: p.producerId,
|
|
345
|
+
paused: p.paused,
|
|
346
|
+
});
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'participantJoined':
|
|
350
|
+
this.emit('participantJoined', data);
|
|
351
|
+
break;
|
|
352
|
+
case 'participantLeft':
|
|
353
|
+
this.emit('participantLeft', data);
|
|
354
|
+
break;
|
|
355
|
+
case 'roleChanged': {
|
|
356
|
+
const e = data;
|
|
357
|
+
this.emit('roleChanged', e);
|
|
358
|
+
// Demoted: the server has already closed our producers. Release the
|
|
359
|
+
// camera and microphone too, instead of capturing for nobody.
|
|
360
|
+
const canPublish = e.role === 'host' || e.role === 'cohost';
|
|
361
|
+
if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
|
|
362
|
+
void this.unpublish().catch(() => undefined);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case 'handRaised':
|
|
367
|
+
case 'handLowered':
|
|
368
|
+
this.emit('handRaised', data);
|
|
369
|
+
break;
|
|
370
|
+
case 'forceMuted': {
|
|
371
|
+
// The server has already paused that kind (an audio mute includes
|
|
372
|
+
// in-app music: it travels as audio). Reflect it locally; a release
|
|
373
|
+
// changes nothing here -- switching back on is the user's choice.
|
|
374
|
+
const e = data;
|
|
375
|
+
if (e.paused) {
|
|
376
|
+
for (const p of this.producers.values()) {
|
|
377
|
+
if (!e.kind || p.kind === e.kind)
|
|
378
|
+
p.pause();
|
|
379
|
+
}
|
|
380
|
+
this.emitLocalMedia();
|
|
381
|
+
}
|
|
382
|
+
this.emit('forceMuted', e);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case 'tokenRefreshed': {
|
|
386
|
+
// A room outlives its join token many times over. The server hands us a
|
|
387
|
+
// fresh one periodically so a reconnect at any point still works --
|
|
388
|
+
// without this, any disconnect after the token's lifetime is terminal.
|
|
389
|
+
const e = data;
|
|
390
|
+
if (e.token)
|
|
391
|
+
this.token = e.token;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case 'mustReconnect': {
|
|
395
|
+
const e = data;
|
|
396
|
+
// A promotion hands us a fresh token carrying the new role.
|
|
397
|
+
if (e.token)
|
|
398
|
+
this.token = e.token;
|
|
399
|
+
this.emit('mustReconnect', e);
|
|
400
|
+
if (e.retry) {
|
|
401
|
+
this.teardownMedia();
|
|
402
|
+
this.ws?.close(1000, 'reassigned');
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'streamInterrupted':
|
|
407
|
+
// Our node survived a failover; the origin did not. Stay connected --
|
|
408
|
+
// pictures come back when the host republishes and the pipe rebuilds.
|
|
409
|
+
this.emit('streamInterrupted', d);
|
|
410
|
+
break;
|
|
411
|
+
case 'usageLimit':
|
|
412
|
+
this.emit('usageLimit', d);
|
|
413
|
+
break;
|
|
414
|
+
case 'roomClosed':
|
|
415
|
+
this.closedByUs = true;
|
|
416
|
+
this.emit('roomClosed', d);
|
|
417
|
+
this.emit('closed', 'room-closed');
|
|
418
|
+
this.close();
|
|
419
|
+
break;
|
|
420
|
+
case 'kicked':
|
|
421
|
+
this.closedByUs = true;
|
|
422
|
+
this.emit('kicked', d);
|
|
423
|
+
this.emit('closed', 'kicked');
|
|
424
|
+
this.close();
|
|
425
|
+
break;
|
|
426
|
+
case 'message':
|
|
427
|
+
this.emit('message', data);
|
|
428
|
+
break;
|
|
429
|
+
case 'messageDeleted':
|
|
430
|
+
this.emit('messageDeleted', data);
|
|
431
|
+
break;
|
|
432
|
+
case 'chatMuted':
|
|
433
|
+
this.emit('chatMuted', data);
|
|
434
|
+
break;
|
|
435
|
+
case 'typing':
|
|
436
|
+
this.emit('typing', data);
|
|
437
|
+
break;
|
|
438
|
+
default:
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// ------------------------------------------------------------------ media
|
|
443
|
+
ensureSendTransport() {
|
|
444
|
+
if (this.sendTransport)
|
|
445
|
+
return Promise.resolve(this.sendTransport);
|
|
446
|
+
if (this.sendTransportPending)
|
|
447
|
+
return this.sendTransportPending;
|
|
448
|
+
this.sendTransportPending = this.createSendTransport().finally(() => {
|
|
449
|
+
this.sendTransportPending = null;
|
|
450
|
+
});
|
|
451
|
+
return this.sendTransportPending;
|
|
452
|
+
}
|
|
453
|
+
async createSendTransport() {
|
|
454
|
+
if (!this.device)
|
|
455
|
+
throw new Error('not connected');
|
|
456
|
+
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
457
|
+
const transport = this.device.createSendTransport({
|
|
458
|
+
...params,
|
|
459
|
+
iceServers: this.joinResult?.iceServers,
|
|
460
|
+
});
|
|
461
|
+
transport.on('connect', ({ dtlsParameters }, ok, fail) => {
|
|
462
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
463
|
+
.then(() => ok())
|
|
464
|
+
.catch(fail);
|
|
465
|
+
});
|
|
466
|
+
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
467
|
+
this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
|
|
468
|
+
.then((res) => ok({ id: res.producerId }))
|
|
469
|
+
.catch(fail);
|
|
470
|
+
});
|
|
471
|
+
this.sendTransport = transport;
|
|
472
|
+
return transport;
|
|
473
|
+
}
|
|
474
|
+
ensureRecvTransport() {
|
|
475
|
+
if (this.recvTransport)
|
|
476
|
+
return Promise.resolve(this.recvTransport);
|
|
477
|
+
if (this.recvTransportPending)
|
|
478
|
+
return this.recvTransportPending;
|
|
479
|
+
this.recvTransportPending = this.createRecvTransport().finally(() => {
|
|
480
|
+
this.recvTransportPending = null;
|
|
481
|
+
});
|
|
482
|
+
return this.recvTransportPending;
|
|
483
|
+
}
|
|
484
|
+
async createRecvTransport() {
|
|
485
|
+
if (!this.device)
|
|
486
|
+
throw new Error('not connected');
|
|
487
|
+
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
488
|
+
const transport = this.device.createRecvTransport({
|
|
489
|
+
...params,
|
|
490
|
+
iceServers: this.joinResult?.iceServers,
|
|
491
|
+
});
|
|
492
|
+
transport.on('connect', ({ dtlsParameters }, ok, fail) => {
|
|
493
|
+
this.rpc('connectTransport', { transportId: transport.id, dtlsParameters })
|
|
494
|
+
.then(() => ok())
|
|
495
|
+
.catch(fail);
|
|
496
|
+
});
|
|
497
|
+
this.recvTransport = transport;
|
|
498
|
+
return transport;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Publish local camera + mic. Only host/cohost may; others get a server error.
|
|
502
|
+
*
|
|
503
|
+
* Pass the constraints used to obtain the stream and they will be reused to
|
|
504
|
+
* reacquire the devices if a reconnect finds the tracks have ended.
|
|
505
|
+
*/
|
|
506
|
+
async publish(stream, constraints) {
|
|
507
|
+
const transport = await this.ensureSendTransport();
|
|
508
|
+
this.localStream = stream;
|
|
509
|
+
this.wasPublishing = true;
|
|
510
|
+
if (constraints)
|
|
511
|
+
this.publishConstraints = constraints;
|
|
512
|
+
const audio = stream.getAudioTracks()[0];
|
|
513
|
+
if (audio) {
|
|
514
|
+
const producer = await transport.produce({
|
|
515
|
+
track: audio,
|
|
516
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
517
|
+
// every producer, and a stopped track cannot be republished.
|
|
518
|
+
stopTracks: false,
|
|
519
|
+
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
520
|
+
appData: { source: 'mic' },
|
|
521
|
+
});
|
|
522
|
+
this.producers.set(producer.id, producer);
|
|
523
|
+
}
|
|
524
|
+
const video = stream.getVideoTracks()[0];
|
|
525
|
+
if (video) {
|
|
526
|
+
// Simulcast from day one: retrofitting it later is genuinely annoying,
|
|
527
|
+
// and it is what lets one node serve far more viewers.
|
|
528
|
+
const producer = await transport.produce({
|
|
529
|
+
track: video,
|
|
530
|
+
stopTracks: false,
|
|
531
|
+
encodings: [
|
|
532
|
+
{ rid: 'q', scaleResolutionDownBy: 4, maxBitrate: 200_000, scalabilityMode: 'L1T3' },
|
|
533
|
+
{ rid: 'h', scaleResolutionDownBy: 2, maxBitrate: 600_000, scalabilityMode: 'L1T3' },
|
|
534
|
+
{ rid: 'f', scaleResolutionDownBy: 1, maxBitrate: 1_800_000, scalabilityMode: 'L1T3' },
|
|
535
|
+
],
|
|
536
|
+
codecOptions: { videoGoogleStartBitrate: 1000 },
|
|
537
|
+
appData: { source: 'camera' },
|
|
538
|
+
});
|
|
539
|
+
this.producers.set(producer.id, producer);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Re-establish this client's broadcast after a reconnect.
|
|
544
|
+
*
|
|
545
|
+
* Reuses the existing tracks when they are still live -- the common case, and
|
|
546
|
+
* it avoids a second camera permission prompt. Only reacquires the devices
|
|
547
|
+
* when the tracks have genuinely ended.
|
|
548
|
+
*/
|
|
549
|
+
async republish() {
|
|
550
|
+
// Music is not a device and cannot be "gone": publish it again whatever
|
|
551
|
+
// happens to the camera and microphone below.
|
|
552
|
+
if (this.music)
|
|
553
|
+
await this.produceMusic(this.music).catch((err) => this.emit('error', err));
|
|
554
|
+
let stream = this.localStream;
|
|
555
|
+
if (!stream && !this.publishConstraints)
|
|
556
|
+
return; // music was all that was being published
|
|
557
|
+
const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
|
|
558
|
+
if (!live) {
|
|
559
|
+
if (!this.publishConstraints) {
|
|
560
|
+
this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
|
|
564
|
+
this.localStream = stream;
|
|
565
|
+
this.emit('localStreamChanged', stream);
|
|
566
|
+
}
|
|
567
|
+
if (!stream)
|
|
568
|
+
return;
|
|
569
|
+
await this.publish(stream);
|
|
570
|
+
}
|
|
571
|
+
// ------------------------------------------------------------------ music
|
|
572
|
+
/**
|
|
573
|
+
* Publish music playing inside the page, as its own track.
|
|
574
|
+
*
|
|
575
|
+
* The audience gets it clean -- it never passes through the microphone --
|
|
576
|
+
* and it has its own switch: `setMusicEnabled(false)` silences the music and
|
|
577
|
+
* leaves the host talking, `setMicEnabled(false)` does the opposite.
|
|
578
|
+
*
|
|
579
|
+
* Call it from a click. Browsers will not start audio processing for a page
|
|
580
|
+
* the user has not interacted with.
|
|
581
|
+
*/
|
|
582
|
+
async publishMusic(input, options) {
|
|
583
|
+
if (this.music)
|
|
584
|
+
await this.stopMusic();
|
|
585
|
+
const music = new MusicSource(input, options);
|
|
586
|
+
try {
|
|
587
|
+
await this.produceMusic(music);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
music.close();
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
this.music = music;
|
|
594
|
+
this.wasPublishing = true;
|
|
595
|
+
this.emitLocalMedia();
|
|
596
|
+
}
|
|
597
|
+
async produceMusic(music) {
|
|
598
|
+
const transport = await this.ensureSendTransport();
|
|
599
|
+
const producer = await transport.produce({
|
|
600
|
+
track: music.track,
|
|
601
|
+
stopTracks: false,
|
|
602
|
+
// Music, not speech. Stereo, no silence suppression (it would chop the
|
|
603
|
+
// quiet parts of a song), and enough bitrate that it sounds like music.
|
|
604
|
+
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
|
|
605
|
+
appData: { source: 'music' },
|
|
606
|
+
});
|
|
607
|
+
this.producers.set(producer.id, producer);
|
|
608
|
+
this.musicProducerId = producer.id;
|
|
609
|
+
}
|
|
610
|
+
/** True while music is published, whether or not it is switched on. */
|
|
611
|
+
get musicPublishing() {
|
|
612
|
+
return this.music !== null;
|
|
613
|
+
}
|
|
614
|
+
get musicEnabled() {
|
|
615
|
+
const producer = this.musicProducerId ? this.producers.get(this.musicProducerId) : undefined;
|
|
616
|
+
return producer ? !producer.paused : false;
|
|
617
|
+
}
|
|
618
|
+
/** 0 to 1, for the audience. The host's own listening level is the element's. */
|
|
619
|
+
setMusicVolume(volume) {
|
|
620
|
+
this.music?.setVolume(volume);
|
|
621
|
+
}
|
|
622
|
+
/** Switch the music off or on for the audience. The microphone is untouched. */
|
|
623
|
+
async setMusicEnabled(enabled) {
|
|
624
|
+
const producer = this.musicProducerId ? this.producers.get(this.musicProducerId) : undefined;
|
|
625
|
+
if (!producer)
|
|
626
|
+
return;
|
|
627
|
+
if (enabled)
|
|
628
|
+
producer.resume();
|
|
629
|
+
else
|
|
630
|
+
producer.pause();
|
|
631
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
632
|
+
this.emitLocalMedia();
|
|
633
|
+
}
|
|
634
|
+
/** Stop publishing music. The element or stream it came from is left as it was. */
|
|
635
|
+
async stopMusic() {
|
|
636
|
+
const id = this.musicProducerId;
|
|
637
|
+
this.musicProducerId = null;
|
|
638
|
+
this.music?.close();
|
|
639
|
+
this.music = null;
|
|
640
|
+
if (id) {
|
|
641
|
+
this.producers.get(id)?.close();
|
|
642
|
+
this.producers.delete(id);
|
|
643
|
+
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
644
|
+
}
|
|
645
|
+
this.emitLocalMedia();
|
|
646
|
+
}
|
|
647
|
+
emitLocalMedia() {
|
|
648
|
+
this.emit('localMediaChanged', {
|
|
649
|
+
micEnabled: this.micEnabled,
|
|
650
|
+
camEnabled: this.camEnabled,
|
|
651
|
+
musicEnabled: this.musicEnabled,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
async consume(remote) {
|
|
655
|
+
if (!this.device)
|
|
656
|
+
throw new Error('not connected');
|
|
657
|
+
if (this.consumers.has(remote.producerId))
|
|
658
|
+
return;
|
|
659
|
+
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
660
|
+
// consumer and shows you a delayed copy of yourself.
|
|
661
|
+
if (remote.identity === this.joinResult?.identity)
|
|
662
|
+
return;
|
|
663
|
+
const transport = await this.ensureRecvTransport();
|
|
664
|
+
const params = (await this.rpc('consume', {
|
|
665
|
+
transportId: transport.id,
|
|
666
|
+
producerId: remote.producerId,
|
|
667
|
+
rtpCapabilities: this.device.rtpCapabilities,
|
|
668
|
+
}));
|
|
669
|
+
const consumer = await transport.consume(params);
|
|
670
|
+
this.consumers.set(remote.producerId, consumer);
|
|
671
|
+
// The server created it paused. Resume only now that our end exists,
|
|
672
|
+
// otherwise the first frames arrive before there is anything to decode them.
|
|
673
|
+
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
674
|
+
const kind = consumer.kind;
|
|
675
|
+
const source = remote.source ?? defaultSource(kind);
|
|
676
|
+
this.remoteSources.set(remote.producerId, source);
|
|
677
|
+
this.emit('track', {
|
|
678
|
+
identity: remote.identity,
|
|
679
|
+
kind,
|
|
680
|
+
track: consumer.track,
|
|
681
|
+
producerId: remote.producerId,
|
|
682
|
+
source,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
// ------------------------------------------------------------- moderation
|
|
686
|
+
// ------------------------------------------------------------ local media
|
|
687
|
+
/** True once this client has at least one live producer. */
|
|
688
|
+
get publishing() {
|
|
689
|
+
return this.producers.size > 0;
|
|
690
|
+
}
|
|
691
|
+
get micEnabled() {
|
|
692
|
+
return this.trackEnabled('audio');
|
|
693
|
+
}
|
|
694
|
+
get camEnabled() {
|
|
695
|
+
return this.trackEnabled('video');
|
|
696
|
+
}
|
|
697
|
+
trackEnabled(kind) {
|
|
698
|
+
for (const p of this.producers.values()) {
|
|
699
|
+
if (p.kind === kind && p.id !== this.musicProducerId)
|
|
700
|
+
return !p.paused;
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Pausing the producer stops RTP at the source, so it costs nothing on the
|
|
706
|
+
* SFU and every viewer is told. Muting only the local track would keep
|
|
707
|
+
* sending silence and leave viewers showing you as unmuted.
|
|
708
|
+
*/
|
|
709
|
+
async setMicEnabled(enabled) {
|
|
710
|
+
await this.setKindEnabled('audio', enabled);
|
|
711
|
+
}
|
|
712
|
+
async setCamEnabled(enabled) {
|
|
713
|
+
await this.setKindEnabled('video', enabled);
|
|
714
|
+
}
|
|
715
|
+
async setKindEnabled(kind, enabled) {
|
|
716
|
+
for (const producer of this.producers.values()) {
|
|
717
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
718
|
+
if (producer.kind !== kind || producer.id === this.musicProducerId)
|
|
719
|
+
continue;
|
|
720
|
+
if (enabled)
|
|
721
|
+
producer.resume();
|
|
722
|
+
else
|
|
723
|
+
producer.pause();
|
|
724
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
725
|
+
}
|
|
726
|
+
this.emitLocalMedia();
|
|
727
|
+
}
|
|
728
|
+
/** Stop publishing entirely and release the local devices. */
|
|
729
|
+
async unpublish() {
|
|
730
|
+
const ids = [...this.producers.keys()];
|
|
731
|
+
for (const producer of this.producers.values())
|
|
732
|
+
producer.close();
|
|
733
|
+
this.producers.clear();
|
|
734
|
+
for (const id of ids) {
|
|
735
|
+
await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
|
|
736
|
+
}
|
|
737
|
+
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
738
|
+
this.localStream = null;
|
|
739
|
+
this.music?.close();
|
|
740
|
+
this.music = null;
|
|
741
|
+
this.musicProducerId = null;
|
|
742
|
+
this.wasPublishing = false;
|
|
743
|
+
this.emit('localStreamChanged', null);
|
|
744
|
+
this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
|
|
745
|
+
}
|
|
746
|
+
get local() {
|
|
747
|
+
return this.localStream;
|
|
748
|
+
}
|
|
749
|
+
// ------------------------------------------------------------- moderation
|
|
750
|
+
raiseHand() { return this.rpc('raiseHand'); }
|
|
751
|
+
lowerHand() { return this.rpc('lowerHand'); }
|
|
752
|
+
muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
|
|
753
|
+
/**
|
|
754
|
+
* Moderating roles: switch someone's microphone (`audio`), camera (`video`)
|
|
755
|
+
* or both (no kind) off at the server, where they cannot switch it back on.
|
|
756
|
+
* `paused: false` releases it; they then turn it on themselves.
|
|
757
|
+
*/
|
|
758
|
+
forceMute(identity, kind, paused = true) {
|
|
759
|
+
return this.rpc('forceMute', { identity, kind: kind ?? '', paused });
|
|
760
|
+
}
|
|
761
|
+
promote(identity, role) { return this.rpc('promote', { identity, role }); }
|
|
762
|
+
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
763
|
+
endRoom() { return this.rpc('endRoom'); }
|
|
764
|
+
getRoomState() { return this.rpc('getRoomState'); }
|
|
765
|
+
// ------------------------------------------------------------------- chat
|
|
766
|
+
//
|
|
767
|
+
// The room's chat rides this same socket. Someone on a chat-only socket
|
|
768
|
+
// (ChatClient) for the same channel is in the same conversation.
|
|
769
|
+
sendMessage(text, opts = {}) {
|
|
770
|
+
return this.rpc('sendMessage', { text, ...opts });
|
|
771
|
+
}
|
|
772
|
+
/** Older messages, oldest first. Pass the oldest id you hold as `before`. */
|
|
773
|
+
async getMessages(opts = {}) {
|
|
774
|
+
const res = (await this.rpc('getMessages', opts));
|
|
775
|
+
return res.messages;
|
|
776
|
+
}
|
|
777
|
+
/** Your own message, or anyone's if you are a moderator. */
|
|
778
|
+
deleteMessage(messageId) { return this.rpc('deleteMessage', { messageId }); }
|
|
779
|
+
/** Moderators only. A mute outlasts the muted socket reconnecting. */
|
|
780
|
+
muteChat(identity, muted = true) { return this.rpc('muteChat', { identity, muted }); }
|
|
781
|
+
/** Advisory, and throttled by the server; safe to call on every keystroke. */
|
|
782
|
+
setTyping(typing) { return this.rpc('setTyping', { typing }); }
|
|
783
|
+
getChatState() { return this.rpc('getChatState'); }
|
|
784
|
+
}
|
|
785
|
+
//# sourceMappingURL=index.js.map
|