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