@voltkit-rtc/client 0.6.0 → 0.7.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 +75 -1
- package/README.md +222 -11
- package/dist/cjs/chat.d.ts +0 -9
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +16 -14
- package/dist/cjs/chat.js.map +1 -1
- package/dist/cjs/errors.d.ts +571 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +155 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.d.ts +298 -16
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +663 -124
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/media-permissions.d.ts +101 -0
- package/dist/cjs/media-permissions.d.ts.map +1 -0
- package/dist/cjs/media-permissions.js +166 -0
- package/dist/cjs/media-permissions.js.map +1 -0
- package/dist/cjs/network.d.ts +80 -0
- package/dist/cjs/network.d.ts.map +1 -0
- package/dist/cjs/network.js +190 -0
- package/dist/cjs/network.js.map +1 -0
- package/dist/cjs/stats.d.ts +16 -2
- package/dist/cjs/stats.d.ts.map +1 -1
- package/dist/cjs/stats.js +53 -5
- package/dist/cjs/stats.js.map +1 -1
- package/dist/cjs/token.d.ts +17 -0
- package/dist/cjs/token.d.ts.map +1 -0
- package/dist/cjs/token.js +36 -0
- package/dist/cjs/token.js.map +1 -0
- package/dist/esm/chat.d.ts +0 -9
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +16 -14
- package/dist/esm/chat.js.map +1 -1
- package/dist/esm/errors.d.ts +571 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +149 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +298 -16
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +642 -115
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/media-permissions.d.ts +101 -0
- package/dist/esm/media-permissions.d.ts.map +1 -0
- package/dist/esm/media-permissions.js +160 -0
- package/dist/esm/media-permissions.js.map +1 -0
- package/dist/esm/network.d.ts +80 -0
- package/dist/esm/network.d.ts.map +1 -0
- package/dist/esm/network.js +183 -0
- package/dist/esm/network.js.map +1 -0
- package/dist/esm/stats.d.ts +16 -2
- package/dist/esm/stats.d.ts.map +1 -1
- package/dist/esm/stats.js +53 -5
- package/dist/esm/stats.js.map +1 -1
- package/dist/esm/token.d.ts +17 -0
- package/dist/esm/token.d.ts.map +1 -0
- package/dist/esm/token.js +31 -0
- package/dist/esm/token.js.map +1 -0
- package/package.json +1 -1
- package/src/chat.ts +7 -14
- package/src/errors.ts +162 -0
- package/src/index.ts +785 -115
- package/src/media-permissions.ts +220 -0
- package/src/network.ts +212 -0
- package/src/stats.ts +51 -6
- package/src/token.ts +31 -0
package/src/index.ts
CHANGED
|
@@ -19,6 +19,19 @@ type Transport = ms.Transport;
|
|
|
19
19
|
type TransportOptions = ms.TransportOptions;
|
|
20
20
|
|
|
21
21
|
import type { ChatEvents, ChatMessage, ChatState, SendMessageOptions } from './chat.js';
|
|
22
|
+
import { isTerminalError, RpcError } from './errors.js';
|
|
23
|
+
import {
|
|
24
|
+
above60fpsCap,
|
|
25
|
+
AUDIO_SETTINGS,
|
|
26
|
+
qualityAt,
|
|
27
|
+
resolvePermissions,
|
|
28
|
+
type AudioQuality,
|
|
29
|
+
type JoinBilling,
|
|
30
|
+
type JoinMedia,
|
|
31
|
+
type MediaPermissions,
|
|
32
|
+
} from './media-permissions.js';
|
|
33
|
+
import { NetworkQualityWatch, type NetworkQuality } from './network.js';
|
|
34
|
+
import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
22
35
|
import {
|
|
23
36
|
chooseCodec,
|
|
24
37
|
currentEnv,
|
|
@@ -66,6 +79,13 @@ export { AudioMixer, MusicSource } from './music.js';
|
|
|
66
79
|
export type { MusicInput, MusicOptions, TrackSource } from './music.js';
|
|
67
80
|
export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
|
|
68
81
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
82
|
+
export { RpcError, ERROR_CODES, errorInfo, isTerminalError } from './errors.js';
|
|
83
|
+
export type { ErrorCode, ErrorInfo, ErrorOrigin } from './errors.js';
|
|
84
|
+
export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePermissions } from './media-permissions.js';
|
|
85
|
+
export type { AudioQuality, BillingMode, JoinBilling, JoinMedia, MediaPermissions, MediaProfile } from './media-permissions.js';
|
|
86
|
+
export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
|
|
87
|
+
export type { NetworkQuality, NetworkQualityLevel } from './network.js';
|
|
88
|
+
export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
69
89
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
70
90
|
export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
|
|
71
91
|
export type { CodecPreference } from './device.js';
|
|
@@ -89,6 +109,59 @@ export interface UsageLimitNotice {
|
|
|
89
109
|
endsAt: number;
|
|
90
110
|
}
|
|
91
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Wallet billing: what the project may still spend covers less than the
|
|
114
|
+
* grace period at the current burn, or (`cleared: true`) a recharge or a
|
|
115
|
+
* lower burn lifted it back above it.
|
|
116
|
+
*/
|
|
117
|
+
export interface WalletLowNotice {
|
|
118
|
+
/** Seconds the funds cover at the current burn; -1 on a cleared notice when nothing limits them. */
|
|
119
|
+
remainingSeconds: number;
|
|
120
|
+
/** True once the warning is over: hide the banner, and the `usageLimit` countdown sent beside it is cancelled. */
|
|
121
|
+
cleared?: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The token this client would reconnect with lapses soon, and nobody has refreshed it. */
|
|
125
|
+
export interface TokenExpiringNotice {
|
|
126
|
+
/** When it expires, in ms since the epoch. */
|
|
127
|
+
expiresAt: number;
|
|
128
|
+
secondsLeft: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
|
|
132
|
+
export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed'] as const);
|
|
133
|
+
export type ConnectionState = (typeof CONNECTION_STATES)[number];
|
|
134
|
+
|
|
135
|
+
export type ClosedReason = 'replaced' | 'kicked' | 'room-closed' | 'gave-up' | 'left';
|
|
136
|
+
|
|
137
|
+
/** A slot changed hands, went away, came back, was locked or was freed. */
|
|
138
|
+
export interface ClaimChange {
|
|
139
|
+
key: string;
|
|
140
|
+
/** Null when the slot is now free or blocked. */
|
|
141
|
+
identity: string | null;
|
|
142
|
+
state: 'held' | 'away' | 'free' | 'blocked';
|
|
143
|
+
/** `claimed`, `moved`, `released`, `disconnected`, `reconnected`, `expired`, `blocked` or `unblocked`. */
|
|
144
|
+
reason: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A track this client is receiving. */
|
|
148
|
+
export interface RemoteTrack {
|
|
149
|
+
producerId: string;
|
|
150
|
+
identity: string;
|
|
151
|
+
kind: 'audio' | 'video';
|
|
152
|
+
source: TrackSource;
|
|
153
|
+
track: MediaStreamTrack;
|
|
154
|
+
/** Its publisher has it switched off (or a moderator does). */
|
|
155
|
+
paused: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What this client publishes, as `localMediaChanged` reports it. */
|
|
159
|
+
export interface LocalMediaState {
|
|
160
|
+
micEnabled: boolean;
|
|
161
|
+
camEnabled: boolean;
|
|
162
|
+
musicEnabled: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
92
165
|
export interface JoinResult {
|
|
93
166
|
roomId: string;
|
|
94
167
|
identity: string;
|
|
@@ -110,9 +183,24 @@ export interface JoinResult {
|
|
|
110
183
|
* on a publisher's incoming bitrate. Absent from older servers: 1080p.
|
|
111
184
|
*/
|
|
112
185
|
video?: VideoPolicy;
|
|
186
|
+
/**
|
|
187
|
+
* The project's media settings, the modes they allow and the fan-out
|
|
188
|
+
* limits. Absent from older servers; `mediaPermissions` resolves either.
|
|
189
|
+
*/
|
|
190
|
+
media?: JoinMedia;
|
|
191
|
+
/** How this session is billed. Absent from older servers. */
|
|
192
|
+
billing?: JoinBilling;
|
|
113
193
|
}
|
|
114
194
|
|
|
115
195
|
export interface PublishOptions {
|
|
196
|
+
/**
|
|
197
|
+
* The microphone: `'std'` (the default) is mono Opus up to 64 kbps, billed
|
|
198
|
+
* as standard audio; `'hd'` is stereo Opus up to 128 kbps, billed as HD
|
|
199
|
+
* audio. HD in a project limited to standard audio sends standard and warns
|
|
200
|
+
* on `error` with `audio-quality-not-allowed`. Change it live with
|
|
201
|
+
* `setAudioQuality`. Music (`publishMusic`) is not affected.
|
|
202
|
+
*/
|
|
203
|
+
audioQuality?: AudioQuality;
|
|
116
204
|
/** 30 by default. Only the top layer uses 60; capture at 60 too. */
|
|
117
205
|
frameRate?: 30 | 60;
|
|
118
206
|
/**
|
|
@@ -158,6 +246,23 @@ export interface ScreenPublishOptions {
|
|
|
158
246
|
stream?: MediaStream;
|
|
159
247
|
}
|
|
160
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The handlers mediasoup-client can make its Device with. `ReactNative106` is
|
|
251
|
+
* reserved for future native hosts (react-native-webrtc); nothing published
|
|
252
|
+
* uses it yet.
|
|
253
|
+
*/
|
|
254
|
+
export type DeviceHandlerName = 'Chrome111' | 'Chrome74' | 'Firefox120' | 'Safari12' | 'ReactNative106';
|
|
255
|
+
|
|
256
|
+
/** `new StreamClient(wsUrl, token, options)`. Everything here is optional; a browser needs none of it. */
|
|
257
|
+
export interface ClientOptions {
|
|
258
|
+
/**
|
|
259
|
+
* The mediasoup-client handler to make the Device with, instead of detecting
|
|
260
|
+
* the browser from its user agent. Leave it unset in browsers; it is
|
|
261
|
+
* reserved for future native hosts, which have no user agent to detect.
|
|
262
|
+
*/
|
|
263
|
+
handlerName?: DeviceHandlerName;
|
|
264
|
+
}
|
|
265
|
+
|
|
161
266
|
export interface SlotClaim {
|
|
162
267
|
key: string;
|
|
163
268
|
identity: string;
|
|
@@ -193,6 +298,8 @@ const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio
|
|
|
193
298
|
export interface ClientEvents extends ChatEvents {
|
|
194
299
|
connected: () => void;
|
|
195
300
|
disconnected: (reason: string) => void;
|
|
301
|
+
/** idle → connecting → connected ⇄ reconnecting → closed. Read it any time as `connectionState`. */
|
|
302
|
+
connectionStateChanged: (e: { state: ConnectionState; previous: ConnectionState }) => void;
|
|
196
303
|
joined: (result: JoinResult) => void;
|
|
197
304
|
track: (info: {
|
|
198
305
|
identity: string;
|
|
@@ -208,6 +315,8 @@ export interface ClientEvents extends ChatEvents {
|
|
|
208
315
|
participantJoined: (p: ParticipantInfo) => void;
|
|
209
316
|
participantLeft: (p: { identity: string; reason?: string }) => void;
|
|
210
317
|
participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
|
|
318
|
+
/** Someone's display metadata changed (their own `setMetadata`). */
|
|
319
|
+
participantUpdated: (e: { identity: string; metadata: unknown }) => void;
|
|
211
320
|
roleChanged: (e: { identity: string; role: Role }) => void;
|
|
212
321
|
handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
|
|
213
322
|
/**
|
|
@@ -216,25 +325,58 @@ export interface ClientEvents extends ChatEvents {
|
|
|
216
325
|
* switch it back on.
|
|
217
326
|
*/
|
|
218
327
|
forceMuted: (e: { by: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
|
|
328
|
+
/**
|
|
329
|
+
* A slot changed. **Draw `away` as taken (greyed), never as free**: the
|
|
330
|
+
* holder's connection dropped and the slot is kept for them. `info.claims`
|
|
331
|
+
* and `info.blockedSlots` are kept current from these.
|
|
332
|
+
*/
|
|
333
|
+
claimChanged: (e: ClaimChange) => void;
|
|
334
|
+
/** Room metadata changed (a moderator's `setRoomMetadata`); persisted, so `info.metadata` is kept current too. */
|
|
335
|
+
roomMetadataChanged: (e: { metadata: unknown; by?: string }) => void;
|
|
219
336
|
mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
|
|
220
337
|
roomClosed: (e: { reason: string }) => void;
|
|
221
338
|
kicked: (e: { by: string; reason?: string }) => void;
|
|
222
339
|
screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
|
|
223
340
|
screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
|
|
341
|
+
/**
|
|
342
|
+
* A failure, or a warning that changes nothing else: an `RpcError` whose
|
|
343
|
+
* `code` is in `ERROR_CODES` (`frame-rate-fallback`,
|
|
344
|
+
* `audio-quality-not-allowed`, ...). Switch on the code.
|
|
345
|
+
*/
|
|
224
346
|
error: (err: Error) => void;
|
|
225
|
-
localMediaChanged: (state:
|
|
347
|
+
localMediaChanged: (state: LocalMediaState) => void;
|
|
226
348
|
/** The local stream was replaced, typically after reacquiring devices on a reconnect. */
|
|
227
349
|
localStreamChanged: (stream: MediaStream | null) => void;
|
|
228
350
|
/** Media stopped arriving but the connection is intact -- the publisher is reconnecting. */
|
|
229
351
|
streamInterrupted: (e: { reason: string }) => void;
|
|
230
352
|
/** The session ended and will not recover. Nothing further will be emitted. */
|
|
231
|
-
closed: (reason:
|
|
353
|
+
closed: (reason: ClosedReason) => void;
|
|
232
354
|
/**
|
|
233
355
|
* The account behind this session ran out of minutes or reached a usage
|
|
234
356
|
* limit. The session ends at `endsAt` (after `graceSeconds`) unless the
|
|
235
357
|
* account is topped up first -- tell the user, and stop asking them to wait.
|
|
236
358
|
*/
|
|
237
359
|
usageLimit: (e: UsageLimitNotice) => void;
|
|
360
|
+
/**
|
|
361
|
+
* Wallet billing: the funds this project may spend cover less than the
|
|
362
|
+
* grace period at the current burn, repeated while it lasts; the room closes
|
|
363
|
+
* when they reach zero unless the wallet is recharged. A notice with
|
|
364
|
+
* `cleared: true` ends the warning (and the `usageLimit` countdown beside it).
|
|
365
|
+
*/
|
|
366
|
+
walletLow: (e: WalletLowNotice) => void;
|
|
367
|
+
/**
|
|
368
|
+
* The token this client would reconnect with expires in about a minute and
|
|
369
|
+
* the server has not refreshed it (it stops when the account may no longer
|
|
370
|
+
* be used, or the socket is down). Mint a new one on your backend and hand it
|
|
371
|
+
* over with `updateToken`.
|
|
372
|
+
*/
|
|
373
|
+
tokenExpiring: (e: TokenExpiringNotice) => void;
|
|
374
|
+
/**
|
|
375
|
+
* Round trip, packet loss and available bandwidth to the media server, about
|
|
376
|
+
* every 3 s while listened to and media is flowing, with a level for the UI
|
|
377
|
+
* (thresholds in the README). Sampled only while someone listens.
|
|
378
|
+
*/
|
|
379
|
+
networkQuality: (e: NetworkQuality) => void;
|
|
238
380
|
/**
|
|
239
381
|
* Your camera video is being held back by this device (`cpu`: too busy to
|
|
240
382
|
* encode it), by the upload (`bandwidth`), or by something else (`other`);
|
|
@@ -248,33 +390,22 @@ export interface ClientEvents extends ChatEvents {
|
|
|
248
390
|
|
|
249
391
|
type Handler = (...args: never[]) => void;
|
|
250
392
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
'room-ended',
|
|
268
|
-
'wrong-tenant',
|
|
269
|
-
'bad-token',
|
|
270
|
-
'no-token',
|
|
271
|
-
'forbidden',
|
|
272
|
-
// The account cannot use the service until it is topped up or restored.
|
|
273
|
-
'quota-exhausted',
|
|
274
|
-
'daily-limit-reached',
|
|
275
|
-
'monthly-limit-reached',
|
|
276
|
-
'tenant-suspended',
|
|
277
|
-
]);
|
|
393
|
+
// Every event a StreamClient emits. Typed against ClientEvents, so adding an
|
|
394
|
+
// event without listing it here (or listing one that does not exist) fails to
|
|
395
|
+
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
396
|
+
const EVENT_NAMES: { readonly [K in keyof ClientEvents]-?: true } = {
|
|
397
|
+
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
398
|
+
track: true, trackEnded: true, trackMuted: true,
|
|
399
|
+
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
400
|
+
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
401
|
+
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
402
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
403
|
+
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
404
|
+
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/** The names of every event `StreamClient.on` accepts. */
|
|
408
|
+
export const CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES) as (keyof ClientEvents)[]);
|
|
278
409
|
|
|
279
410
|
interface Pending {
|
|
280
411
|
resolve: (v: unknown) => void;
|
|
@@ -306,7 +437,26 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
306
437
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
307
438
|
|
|
308
439
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
309
|
-
const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published
|
|
440
|
+
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
444
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
445
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
446
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
447
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
448
|
+
* audio go out as they always did.
|
|
449
|
+
*/
|
|
450
|
+
function declareAudioQuality(rtpParameters: ms.RtpParameters, appData: Record<string, unknown>): ms.RtpParameters {
|
|
451
|
+
const quality = appData.audioQuality;
|
|
452
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd')) return rtpParameters;
|
|
453
|
+
const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
|
|
454
|
+
return {
|
|
455
|
+
...rtpParameters,
|
|
456
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
457
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
310
460
|
|
|
311
461
|
export class StreamClient {
|
|
312
462
|
private ws: WebSocket | null = null;
|
|
@@ -379,12 +529,38 @@ export class StreamClient {
|
|
|
379
529
|
private readonly sendStatsBaseline: StatsBaseline = new Map();
|
|
380
530
|
private readonly recvStatsBaseline: StatsBaseline = new Map();
|
|
381
531
|
private readonly limitWatch = new VideoLimitWatch(
|
|
382
|
-
(baseline) => this.sampleCamera(baseline),
|
|
532
|
+
(baseline, isCurrent) => this.sampleCamera(baseline, isCurrent),
|
|
383
533
|
(event) => this.emit('videoLimited', event),
|
|
534
|
+
undefined,
|
|
535
|
+
() => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; },
|
|
536
|
+
);
|
|
537
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
538
|
+
private state: ConnectionState = 'idle';
|
|
539
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
540
|
+
private tokenExpiry: number | null;
|
|
541
|
+
private tokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
542
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
543
|
+
private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
|
|
544
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
545
|
+
private readonly skippedVideos = new Map<string, RemoteProducer>();
|
|
546
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
547
|
+
private readonly videoSlots = new Set<string>();
|
|
548
|
+
/** The quality the microphone producer was made with. */
|
|
549
|
+
private micQuality: AudioQuality = 'std';
|
|
550
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
551
|
+
private micQueue: Promise<unknown> = Promise.resolve();
|
|
552
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
553
|
+
private audioCapReported = false;
|
|
554
|
+
private sizeCapReported = false;
|
|
555
|
+
private readonly networkWatch = new NetworkQualityWatch(
|
|
556
|
+
() => this.transportStats(),
|
|
557
|
+
() => this.producers.size > 0,
|
|
558
|
+
(quality) => this.emit('networkQuality', quality),
|
|
384
559
|
);
|
|
385
560
|
|
|
386
|
-
constructor(private readonly wsUrl: string, token: string) {
|
|
561
|
+
constructor(private readonly wsUrl: string, token: string, private readonly options: ClientOptions = {}) {
|
|
387
562
|
this.token = token;
|
|
563
|
+
this.tokenExpiry = tokenExpiresAt(token);
|
|
388
564
|
}
|
|
389
565
|
|
|
390
566
|
on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
|
|
@@ -394,14 +570,43 @@ export class StreamClient {
|
|
|
394
570
|
this.listeners.set(event, set);
|
|
395
571
|
}
|
|
396
572
|
set.add(fn as Handler);
|
|
397
|
-
// videoLimited
|
|
573
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
574
|
+
// they run only while heard.
|
|
398
575
|
if (event === 'videoLimited') this.syncLimitWatch();
|
|
576
|
+
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
399
577
|
return () => {
|
|
400
578
|
set!.delete(fn as Handler);
|
|
401
579
|
if (event === 'videoLimited') this.syncLimitWatch();
|
|
580
|
+
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
402
581
|
};
|
|
403
582
|
}
|
|
404
583
|
|
|
584
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
585
|
+
get connectionState(): ConnectionState {
|
|
586
|
+
return this.state;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private setState(next: ConnectionState): void {
|
|
590
|
+
if (next === this.state) return;
|
|
591
|
+
const previous = this.state;
|
|
592
|
+
this.state = next;
|
|
593
|
+
if (next === 'closed') this.stopTimers();
|
|
594
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Over for good: the state, then the event that says why. */
|
|
598
|
+
private finish(reason: ClosedReason): void {
|
|
599
|
+
this.closedByUs = true;
|
|
600
|
+
this.setState('closed');
|
|
601
|
+
this.emit('closed', reason);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private stopTimers(): void {
|
|
605
|
+
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
606
|
+
this.tokenTimer = null;
|
|
607
|
+
this.networkWatch.set(false);
|
|
608
|
+
}
|
|
609
|
+
|
|
405
610
|
private listenerCount(event: keyof ClientEvents): number {
|
|
406
611
|
return this.listeners.get(event)?.size ?? 0;
|
|
407
612
|
}
|
|
@@ -420,6 +625,7 @@ export class StreamClient {
|
|
|
420
625
|
|
|
421
626
|
async connect(): Promise<JoinResult> {
|
|
422
627
|
let result: JoinResult;
|
|
628
|
+
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
423
629
|
try {
|
|
424
630
|
await this.openSocket();
|
|
425
631
|
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
@@ -431,7 +637,10 @@ export class StreamClient {
|
|
|
431
637
|
// A session that never got going: the app has the error. Left open,
|
|
432
638
|
// the socket would be closed by the server's join deadline instead, and
|
|
433
639
|
// that close would start reconnecting a session that never began.
|
|
434
|
-
if (!this.joinedOnce)
|
|
640
|
+
if (!this.joinedOnce) {
|
|
641
|
+
this.detachSocket();
|
|
642
|
+
if (this.state === 'connecting') this.setState('idle');
|
|
643
|
+
}
|
|
435
644
|
throw err;
|
|
436
645
|
}
|
|
437
646
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -440,6 +649,9 @@ export class StreamClient {
|
|
|
440
649
|
// retried about twice a second, forever.
|
|
441
650
|
this.reconnectAttempt = 0;
|
|
442
651
|
this.joinedOnce = true;
|
|
652
|
+
this.skippedVideos.clear();
|
|
653
|
+
this.setState('connected');
|
|
654
|
+
this.scheduleTokenExpiring();
|
|
443
655
|
|
|
444
656
|
// A role or mute can change while the socket is away. The fresh roster
|
|
445
657
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
@@ -475,12 +687,18 @@ export class StreamClient {
|
|
|
475
687
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
476
688
|
await this.republish().catch((err) => this.emit('error', err as Error));
|
|
477
689
|
}
|
|
690
|
+
this.syncNetworkWatch();
|
|
478
691
|
return result;
|
|
479
692
|
}
|
|
480
693
|
|
|
481
|
-
/**
|
|
694
|
+
/**
|
|
695
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
696
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
697
|
+
* WebRTC, can stand one in.
|
|
698
|
+
*/
|
|
482
699
|
private createDevice(): Device {
|
|
483
|
-
|
|
700
|
+
const { handlerName } = this.options;
|
|
701
|
+
return handlerName ? new Device({ handlerName }) : new Device();
|
|
484
702
|
}
|
|
485
703
|
|
|
486
704
|
private openSocket(): Promise<void> {
|
|
@@ -490,16 +708,14 @@ export class StreamClient {
|
|
|
490
708
|
this.detachSocket();
|
|
491
709
|
|
|
492
710
|
return new Promise((resolve, reject) => {
|
|
493
|
-
const
|
|
494
|
-
url.searchParams.set('token', this.token);
|
|
495
|
-
const ws = new WebSocket(url.toString());
|
|
711
|
+
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
496
712
|
this.ws = ws;
|
|
497
713
|
|
|
498
714
|
const onOpen = () => {
|
|
499
715
|
this.emit('connected');
|
|
500
716
|
resolve();
|
|
501
717
|
};
|
|
502
|
-
const onError = () => reject(new
|
|
718
|
+
const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
503
719
|
|
|
504
720
|
ws.addEventListener('open', onOpen, { once: true });
|
|
505
721
|
ws.addEventListener('error', onError, { once: true });
|
|
@@ -511,32 +727,32 @@ export class StreamClient {
|
|
|
511
727
|
// Only the socket we currently consider ours may drive reconnection.
|
|
512
728
|
if (this.ws !== ws) return;
|
|
513
729
|
this.ws = null;
|
|
514
|
-
this.failAllPending(new
|
|
730
|
+
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
515
731
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
516
732
|
|
|
517
733
|
if (this.closedByUs) return;
|
|
518
734
|
if (ev.code === CLOSE_REPLACED) {
|
|
519
735
|
// Another session for this identity is live. Reconnecting would
|
|
520
736
|
// displace it, and it would displace us right back, forever.
|
|
521
|
-
this.emit('error', new
|
|
522
|
-
this.closedByUs = true;
|
|
737
|
+
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
523
738
|
// The producers outlive this close until the app calls close(); the
|
|
524
739
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
525
740
|
this.limitWatch.reset();
|
|
526
|
-
this.
|
|
741
|
+
this.finish('replaced');
|
|
527
742
|
return;
|
|
528
743
|
}
|
|
529
744
|
if (ev.code === CLOSE_ENDED) {
|
|
530
745
|
// Normally roomClosed has already ended the session; this covers a
|
|
531
746
|
// close that arrived without it.
|
|
532
|
-
this.closedByUs = true;
|
|
533
747
|
this.limitWatch.reset();
|
|
534
|
-
this.
|
|
748
|
+
this.finish('room-closed');
|
|
535
749
|
return;
|
|
536
750
|
}
|
|
537
751
|
// Before a first successful join the app is still waiting on
|
|
538
752
|
// connect(), which reports the failure; there is nothing to restore.
|
|
539
753
|
if (!this.joinedOnce) return;
|
|
754
|
+
this.setState('reconnecting');
|
|
755
|
+
this.networkWatch.set(false);
|
|
540
756
|
void this.scheduleReconnect();
|
|
541
757
|
});
|
|
542
758
|
});
|
|
@@ -569,9 +785,8 @@ export class StreamClient {
|
|
|
569
785
|
while (!this.closedByUs) {
|
|
570
786
|
this.reconnectAttempt += 1;
|
|
571
787
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
572
|
-
this.emit('error', new
|
|
573
|
-
this.
|
|
574
|
-
this.emit('closed', 'gave-up');
|
|
788
|
+
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
789
|
+
this.finish('gave-up');
|
|
575
790
|
return;
|
|
576
791
|
}
|
|
577
792
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -587,12 +802,11 @@ export class StreamClient {
|
|
|
587
802
|
if (this.ws) return;
|
|
588
803
|
} catch (err) {
|
|
589
804
|
this.emit('error', err as Error);
|
|
590
|
-
// Stop on failures a retry cannot resolve
|
|
591
|
-
// ended produces one error per
|
|
592
|
-
// whatever actually went wrong.
|
|
593
|
-
if (err instanceof RpcError &&
|
|
594
|
-
this.
|
|
595
|
-
this.emit('closed', 'gave-up');
|
|
805
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
806
|
+
// codes). Hammering a room that has ended produces one error per
|
|
807
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
808
|
+
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
809
|
+
this.finish('gave-up');
|
|
596
810
|
return;
|
|
597
811
|
}
|
|
598
812
|
}
|
|
@@ -619,6 +833,7 @@ export class StreamClient {
|
|
|
619
833
|
this.music = null;
|
|
620
834
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
621
835
|
this.limitWatch.reset();
|
|
836
|
+
this.skippedVideos.clear();
|
|
622
837
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
623
838
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
624
839
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -626,12 +841,16 @@ export class StreamClient {
|
|
|
626
841
|
const ws = this.ws;
|
|
627
842
|
this.ws = null;
|
|
628
843
|
ws?.close(1000, 'client closed');
|
|
844
|
+
this.setState('closed');
|
|
629
845
|
if (wasOpen) this.emit('closed', 'left');
|
|
630
846
|
}
|
|
631
847
|
|
|
632
848
|
private teardownMedia(): void {
|
|
633
849
|
for (const c of this.consumers.values()) c.close();
|
|
634
850
|
this.consumers.clear();
|
|
851
|
+
this.remoteInfo.clear();
|
|
852
|
+
this.skippedVideos.clear();
|
|
853
|
+
this.videoSlots.clear();
|
|
635
854
|
for (const p of this.producers.values()) p.close();
|
|
636
855
|
this.producers.clear();
|
|
637
856
|
this.musicProducerId = null;
|
|
@@ -647,25 +866,142 @@ export class StreamClient {
|
|
|
647
866
|
this.sendTransportPending = null;
|
|
648
867
|
this.recvTransportPending = null;
|
|
649
868
|
this.device = null;
|
|
869
|
+
this.syncNetworkWatch();
|
|
650
870
|
}
|
|
651
871
|
|
|
652
|
-
/**
|
|
872
|
+
/**
|
|
873
|
+
* Everything currently known about the room, for rendering: the join
|
|
874
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
875
|
+
* metadata, slots and room metadata follow the events.
|
|
876
|
+
*/
|
|
653
877
|
get info(): JoinResult | null {
|
|
654
878
|
return this.joinResult;
|
|
655
879
|
}
|
|
656
880
|
|
|
881
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
882
|
+
get participants(): ParticipantInfo[] {
|
|
883
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Every track this client is receiving now. */
|
|
887
|
+
get remoteTracks(): RemoteTrack[] {
|
|
888
|
+
const out: RemoteTrack[] = [];
|
|
889
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
890
|
+
const info = this.remoteInfo.get(producerId);
|
|
891
|
+
if (!info || consumer.closed) continue;
|
|
892
|
+
out.push({
|
|
893
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
894
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
return out;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* What this project lets this client publish: from the join result's
|
|
902
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
903
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
904
|
+
* or caps anything else with the server's own codes.
|
|
905
|
+
*/
|
|
906
|
+
get mediaPermissions(): MediaPermissions {
|
|
907
|
+
return resolvePermissions(this.joinResult);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
911
|
+
get billingMode(): 'legacy' | 'wallet' | null {
|
|
912
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ------------------------------------------------------------ token refresh
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
919
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
920
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
921
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
922
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
923
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
924
|
+
*/
|
|
925
|
+
async updateToken(token: string): Promise<{ expiresAt: number | null }> {
|
|
926
|
+
if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
|
|
927
|
+
if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
928
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
929
|
+
try {
|
|
930
|
+
const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
|
|
931
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
932
|
+
this.setToken(token, at);
|
|
933
|
+
return { expiresAt: this.tokenExpiry };
|
|
934
|
+
} catch (err) {
|
|
935
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
936
|
+
// during the call is reconnecting: either way keep the token for the
|
|
937
|
+
// next join, which checks it.
|
|
938
|
+
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
this.setToken(token, null);
|
|
942
|
+
return { expiresAt: this.tokenExpiry };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
946
|
+
private setToken(token: string, expiresAt: number | null): void {
|
|
947
|
+
this.token = token;
|
|
948
|
+
this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
|
|
949
|
+
this.scheduleTokenExpiring();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
954
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
955
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
956
|
+
*/
|
|
957
|
+
private scheduleTokenExpiring(): void {
|
|
958
|
+
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
959
|
+
this.tokenTimer = null;
|
|
960
|
+
const expiresAt = this.tokenExpiry;
|
|
961
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed') return;
|
|
962
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
963
|
+
const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
964
|
+
this.tokenTimer = setTimeout(() => {
|
|
965
|
+
this.tokenTimer = null;
|
|
966
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt) return;
|
|
967
|
+
if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
968
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
972
|
+
}, delay);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ------------------------------------------------------------ network quality
|
|
976
|
+
|
|
977
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
978
|
+
private syncNetworkWatch(): void {
|
|
979
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
983
|
+
private async transportStats(): Promise<Map<string, Iterable<Record<string, unknown>>>> {
|
|
984
|
+
const out = new Map<string, Iterable<Record<string, unknown>>>();
|
|
985
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
986
|
+
if (!transport || transport.closed) continue;
|
|
987
|
+
const report = await transport.getStats();
|
|
988
|
+
out.set(transport.id, report.values() as Iterable<Record<string, unknown>>);
|
|
989
|
+
}
|
|
990
|
+
return out;
|
|
991
|
+
}
|
|
992
|
+
|
|
657
993
|
// -------------------------------------------------------------------- rpc
|
|
658
994
|
|
|
659
995
|
private rpc(method: string, params: unknown = {}): Promise<unknown> {
|
|
660
996
|
return new Promise((resolve, reject) => {
|
|
661
997
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
662
|
-
reject(new
|
|
998
|
+
reject(new RpcError('not-connected', 'socket is not open'));
|
|
663
999
|
return;
|
|
664
1000
|
}
|
|
665
1001
|
const id = this.nextId++;
|
|
666
1002
|
const timer = setTimeout(() => {
|
|
667
1003
|
this.pending.delete(id);
|
|
668
|
-
reject(new
|
|
1004
|
+
reject(new RpcError('timeout', `${method} timed out`));
|
|
669
1005
|
}, RPC_TIMEOUT_MS);
|
|
670
1006
|
this.pending.set(id, { resolve, reject, timer });
|
|
671
1007
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -695,8 +1031,8 @@ export class StreamClient {
|
|
|
695
1031
|
this.pending.delete(msg.id);
|
|
696
1032
|
if (msg.ok) p.resolve(msg.result);
|
|
697
1033
|
else {
|
|
698
|
-
const e = msg.error as { code?: string; message?: string } | undefined;
|
|
699
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
1034
|
+
const e = msg.error as { code?: string; message?: string; data?: unknown } | undefined;
|
|
1035
|
+
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
700
1036
|
}
|
|
701
1037
|
return;
|
|
702
1038
|
}
|
|
@@ -715,16 +1051,24 @@ export class StreamClient {
|
|
|
715
1051
|
this.preferredLayers.delete(producerId);
|
|
716
1052
|
this.receiveQualities.delete(producerId);
|
|
717
1053
|
this.remoteVideoLayers.delete(producerId);
|
|
1054
|
+
this.skippedVideos.delete(producerId);
|
|
718
1055
|
const consumer = this.consumers.get(producerId);
|
|
719
1056
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
1057
|
+
this.videoSlots.delete(producerId);
|
|
720
1058
|
consumer.close();
|
|
721
1059
|
this.consumers.delete(producerId);
|
|
722
1060
|
this.remoteSources.delete(producerId);
|
|
1061
|
+
this.remoteInfo.delete(producerId);
|
|
723
1062
|
this.emit('trackEnded', { producerId, identity });
|
|
1063
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
1064
|
+
// waiting takes it.
|
|
1065
|
+
if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
|
|
724
1066
|
break;
|
|
725
1067
|
}
|
|
726
1068
|
case 'producerPaused': {
|
|
727
1069
|
const p = data as { producerId: string; identity: string; kind: 'audio' | 'video'; paused: boolean };
|
|
1070
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
1071
|
+
if (info) info.paused = Boolean(p.paused);
|
|
728
1072
|
this.emit('trackMuted', {
|
|
729
1073
|
identity: p.identity,
|
|
730
1074
|
kind: p.kind,
|
|
@@ -734,26 +1078,40 @@ export class StreamClient {
|
|
|
734
1078
|
});
|
|
735
1079
|
break;
|
|
736
1080
|
}
|
|
737
|
-
case 'participantJoined':
|
|
738
|
-
|
|
1081
|
+
case 'participantJoined': {
|
|
1082
|
+
const p = data as ParticipantInfo;
|
|
1083
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
1084
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
1085
|
+
this.emit('participantJoined', p);
|
|
739
1086
|
break;
|
|
1087
|
+
}
|
|
740
1088
|
case 'participantMuted': {
|
|
741
1089
|
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
742
1090
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
743
|
-
|
|
1091
|
+
this.updateRoster((list) => list.map((p) =>
|
|
744
1092
|
p.identity !== e.identity ? p : { ...p,
|
|
745
1093
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
746
1094
|
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
747
|
-
});
|
|
1095
|
+
}));
|
|
748
1096
|
this.emit('participantMuted', e);
|
|
749
1097
|
break;
|
|
750
1098
|
}
|
|
751
|
-
case '
|
|
752
|
-
|
|
1099
|
+
case 'participantUpdated': {
|
|
1100
|
+
const e = data as { identity: string; metadata?: unknown };
|
|
1101
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
1102
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
753
1103
|
break;
|
|
1104
|
+
}
|
|
1105
|
+
case 'participantLeft': {
|
|
1106
|
+
const e = data as { identity: string; reason?: string };
|
|
1107
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
1108
|
+
this.emit('participantLeft', e);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
754
1111
|
case 'roleChanged': {
|
|
755
1112
|
const e = data as { identity: string; role: Role };
|
|
756
1113
|
if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
|
|
1114
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
757
1115
|
this.emit('roleChanged', e);
|
|
758
1116
|
// Demoted: the server has already closed our producers. Release the
|
|
759
1117
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -764,9 +1122,13 @@ export class StreamClient {
|
|
|
764
1122
|
break;
|
|
765
1123
|
}
|
|
766
1124
|
case 'handRaised':
|
|
767
|
-
case 'handLowered':
|
|
1125
|
+
case 'handLowered': {
|
|
1126
|
+
const e = data as { identity: string; handRaised: boolean; metadata?: Record<string, unknown> };
|
|
1127
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
1128
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
768
1129
|
this.emit('handRaised', data as { identity: string; handRaised: boolean });
|
|
769
1130
|
break;
|
|
1131
|
+
}
|
|
770
1132
|
case 'forceMuted': {
|
|
771
1133
|
// The server has already paused that kind (an audio mute includes
|
|
772
1134
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -779,18 +1141,30 @@ export class StreamClient {
|
|
|
779
1141
|
this.emit('forceMuted', e);
|
|
780
1142
|
break;
|
|
781
1143
|
}
|
|
1144
|
+
case 'claimChanged': {
|
|
1145
|
+
const e = data as { key: string; identity?: string | null; state: ClaimChange['state']; reason?: string };
|
|
1146
|
+
this.applyClaim(e);
|
|
1147
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
case 'roomMetadataChanged': {
|
|
1151
|
+
const e = data as { metadata?: unknown; by?: string };
|
|
1152
|
+
if (this.joinResult) this.joinResult.metadata = e.metadata;
|
|
1153
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
782
1156
|
case 'tokenRefreshed': {
|
|
783
1157
|
// A room outlives its join token many times over. The server hands us a
|
|
784
1158
|
// fresh one periodically so a reconnect at any point still works --
|
|
785
1159
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
786
|
-
const e = data as { token?: string; role?: Role };
|
|
787
|
-
if (e.token) this.token
|
|
1160
|
+
const e = data as { token?: string; role?: Role; expiresAt?: number };
|
|
1161
|
+
if (e.token) this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
788
1162
|
break;
|
|
789
1163
|
}
|
|
790
1164
|
case 'mustReconnect': {
|
|
791
1165
|
const e = data as { reason: string; token?: string; retry: boolean };
|
|
792
1166
|
// A promotion hands us a fresh token carrying the new role.
|
|
793
|
-
if (e.token) this.
|
|
1167
|
+
if (e.token) this.setToken(e.token, null);
|
|
794
1168
|
this.emit('mustReconnect', e);
|
|
795
1169
|
if (e.retry) {
|
|
796
1170
|
this.teardownMedia();
|
|
@@ -806,16 +1180,22 @@ export class StreamClient {
|
|
|
806
1180
|
case 'usageLimit':
|
|
807
1181
|
this.emit('usageLimit', d as never);
|
|
808
1182
|
break;
|
|
1183
|
+
case 'walletLow': {
|
|
1184
|
+
const e = data as { remainingSeconds?: unknown; cleared?: unknown };
|
|
1185
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
1186
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
809
1189
|
case 'roomClosed':
|
|
810
1190
|
this.closedByUs = true;
|
|
811
1191
|
this.emit('roomClosed', d as never);
|
|
812
|
-
this.
|
|
1192
|
+
this.finish('room-closed');
|
|
813
1193
|
this.close();
|
|
814
1194
|
break;
|
|
815
1195
|
case 'kicked':
|
|
816
1196
|
this.closedByUs = true;
|
|
817
1197
|
this.emit('kicked', d as never);
|
|
818
|
-
this.
|
|
1198
|
+
this.finish('kicked');
|
|
819
1199
|
this.close();
|
|
820
1200
|
break;
|
|
821
1201
|
case 'message':
|
|
@@ -835,6 +1215,23 @@ export class StreamClient {
|
|
|
835
1215
|
}
|
|
836
1216
|
}
|
|
837
1217
|
|
|
1218
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
1219
|
+
private updateRoster(change: (list: ParticipantInfo[]) => ParticipantInfo[]): void {
|
|
1220
|
+
if (this.joinResult) this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
1224
|
+
private applyClaim(e: { key: string; identity?: string | null; state: string }): void {
|
|
1225
|
+
const join = this.joinResult;
|
|
1226
|
+
if (!join) return;
|
|
1227
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
1228
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
1229
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity) claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
1230
|
+
if (e.state === 'blocked') blocked = [...blocked, e.key];
|
|
1231
|
+
join.claims = claims;
|
|
1232
|
+
join.blockedSlots = blocked;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
838
1235
|
// ------------------------------------------------------------------ media
|
|
839
1236
|
|
|
840
1237
|
private ensureSendTransport(): Promise<Transport> {
|
|
@@ -847,7 +1244,7 @@ export class StreamClient {
|
|
|
847
1244
|
}
|
|
848
1245
|
|
|
849
1246
|
private async createSendTransport(): Promise<Transport> {
|
|
850
|
-
if (!this.device) throw new
|
|
1247
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
851
1248
|
|
|
852
1249
|
const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
853
1250
|
const transport = this.device.createSendTransport({
|
|
@@ -861,7 +1258,10 @@ export class StreamClient {
|
|
|
861
1258
|
.catch(fail);
|
|
862
1259
|
});
|
|
863
1260
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
864
|
-
this.rpc('produce', {
|
|
1261
|
+
this.rpc('produce', {
|
|
1262
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
1263
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
1264
|
+
})
|
|
865
1265
|
.then((res) => ok({ id: (res as { producerId: string }).producerId }))
|
|
866
1266
|
.catch(fail);
|
|
867
1267
|
});
|
|
@@ -880,7 +1280,7 @@ export class StreamClient {
|
|
|
880
1280
|
}
|
|
881
1281
|
|
|
882
1282
|
private async createRecvTransport(): Promise<Transport> {
|
|
883
|
-
if (!this.device) throw new
|
|
1283
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
884
1284
|
|
|
885
1285
|
const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
886
1286
|
const transport = this.device.createRecvTransport({
|
|
@@ -910,11 +1310,24 @@ export class StreamClient {
|
|
|
910
1310
|
* kept for a reconnect's republish.
|
|
911
1311
|
*/
|
|
912
1312
|
async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
1313
|
+
// A kind the project switched off is refused here, before anything is
|
|
1314
|
+
// produced, with the code the controller would answer with.
|
|
1315
|
+
const permissions = this.mediaPermissions;
|
|
1316
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
1317
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1318
|
+
}
|
|
1319
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
1320
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1321
|
+
}
|
|
1322
|
+
await this.publishStream(stream, constraints, options);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
private async publishStream(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
913
1326
|
const generation = this.publishGeneration;
|
|
914
1327
|
const transport = await this.ensureSendTransport();
|
|
915
1328
|
// unpublish() ran first: this publish is over before it began, and must
|
|
916
1329
|
// leave nothing behind that a reconnect would publish again.
|
|
917
|
-
if (generation !== this.publishGeneration) throw
|
|
1330
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
|
|
918
1331
|
this.localStream = stream;
|
|
919
1332
|
this.wasPublishing = true;
|
|
920
1333
|
if (constraints) this.publishConstraints = constraints;
|
|
@@ -922,39 +1335,144 @@ export class StreamClient {
|
|
|
922
1335
|
this.publishOptions = { ...options };
|
|
923
1336
|
this.frameRateFallback = false;
|
|
924
1337
|
this.frameRateLowSamples = 0;
|
|
1338
|
+
this.audioCapReported = false;
|
|
1339
|
+
this.sizeCapReported = false;
|
|
925
1340
|
}
|
|
1341
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
1342
|
+
// what is still allowed goes out, and the rest is reported.
|
|
1343
|
+
const permissions = this.mediaPermissions;
|
|
926
1344
|
|
|
927
1345
|
const audio = stream.getAudioTracks()[0];
|
|
928
|
-
if (audio) {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
// every producer, and a stopped track cannot be republished.
|
|
933
|
-
stopTracks: false,
|
|
934
|
-
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
935
|
-
appData: { source: 'mic' },
|
|
936
|
-
});
|
|
937
|
-
await this.keepProducer(producer, generation, 'microphone');
|
|
938
|
-
// A microphone handed over switched off, or muted before a reconnect.
|
|
939
|
-
await this.pauseOnServer(producer);
|
|
1346
|
+
if (audio && !permissions.audio) {
|
|
1347
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
1348
|
+
} else if (audio) {
|
|
1349
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
940
1350
|
}
|
|
941
1351
|
|
|
942
1352
|
const video = stream.getVideoTracks()[0];
|
|
943
|
-
if (video) {
|
|
1353
|
+
if (video && !permissions.video) {
|
|
1354
|
+
this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
1355
|
+
} else if (video) {
|
|
944
1356
|
// The track is read when the camera's turn comes: one handed to
|
|
945
1357
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
946
1358
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
947
1359
|
}
|
|
948
1360
|
}
|
|
949
1361
|
|
|
1362
|
+
// ------------------------------------------------------------ audio quality
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* The microphone's audio quality: what it is being sent at while
|
|
1366
|
+
* published, else what the next publish will use, after the project's limit.
|
|
1367
|
+
*/
|
|
1368
|
+
get audioQuality(): AudioQuality {
|
|
1369
|
+
if (this.micProducer()) return this.micQuality;
|
|
1370
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
1375
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
1376
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
1377
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
1378
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
1379
|
+
* only. Resolves to the quality in use.
|
|
1380
|
+
*/
|
|
1381
|
+
async setAudioQuality(quality: AudioQuality): Promise<AudioQuality> {
|
|
1382
|
+
if (quality !== 'std' && quality !== 'hd') throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
1383
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
1384
|
+
throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
1385
|
+
}
|
|
1386
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
1387
|
+
this.audioCapReported = false;
|
|
1388
|
+
await this.micTask(async () => {
|
|
1389
|
+
const current = this.micProducer();
|
|
1390
|
+
const track = current?.track;
|
|
1391
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
|
|
1392
|
+
await this.reproduceMic(current, track);
|
|
1393
|
+
});
|
|
1394
|
+
return this.audioQuality;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private micProducer(): Producer | undefined {
|
|
1398
|
+
for (const p of this.producers.values()) {
|
|
1399
|
+
if (p.kind === 'audio' && (p.appData as { source?: string }).source === 'mic') return p;
|
|
1400
|
+
}
|
|
1401
|
+
return undefined;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private micTask<T>(task: () => Promise<T>): Promise<T> {
|
|
1405
|
+
const run = this.micQueue.then(task, task);
|
|
1406
|
+
this.micQueue = run.catch(() => undefined);
|
|
1407
|
+
return run;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1411
|
+
private audioQualityFor(): AudioQuality {
|
|
1412
|
+
if (this.publishOptions.audioQuality !== 'hd') return 'std';
|
|
1413
|
+
if (this.mediaPermissions.audioQualityMax === 'hd') return 'hd';
|
|
1414
|
+
if (!this.audioCapReported) {
|
|
1415
|
+
this.audioCapReported = true;
|
|
1416
|
+
this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1417
|
+
}
|
|
1418
|
+
return 'std';
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1423
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1424
|
+
* paused, on both ends.
|
|
1425
|
+
*/
|
|
1426
|
+
private async produceMic(transport: Transport, track: MediaStreamTrack, generation: number): Promise<Producer> {
|
|
1427
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
|
|
1428
|
+
const quality = this.audioQualityFor();
|
|
1429
|
+
const settings = AUDIO_SETTINGS[quality];
|
|
1430
|
+
const producer = await transport.produce({
|
|
1431
|
+
track,
|
|
1432
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1433
|
+
// every producer, and a stopped track cannot be republished.
|
|
1434
|
+
stopTracks: false,
|
|
1435
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1436
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1437
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1438
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1439
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1440
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1441
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1442
|
+
});
|
|
1443
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1444
|
+
this.micQuality = quality;
|
|
1445
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1446
|
+
await this.pauseOnServer(producer);
|
|
1447
|
+
return producer;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1451
|
+
private async reproduceMic(current: Producer, track: MediaStreamTrack): Promise<Producer> {
|
|
1452
|
+
const generation = this.publishGeneration;
|
|
1453
|
+
current.close();
|
|
1454
|
+
this.producers.delete(current.id);
|
|
1455
|
+
// The server allows one producer per source: the old one goes first.
|
|
1456
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1457
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
950
1460
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
951
1461
|
get videoPolicy(): VideoPolicy {
|
|
952
1462
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
953
1463
|
}
|
|
954
1464
|
|
|
955
|
-
/**
|
|
1465
|
+
/**
|
|
1466
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1467
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1468
|
+
*/
|
|
956
1469
|
get videoQuality(): VideoQuality {
|
|
957
|
-
return
|
|
1470
|
+
return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1474
|
+
get videoFrameRate(): 30 | 60 {
|
|
1475
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
958
1476
|
}
|
|
959
1477
|
|
|
960
1478
|
/**
|
|
@@ -965,6 +1483,7 @@ export class StreamClient {
|
|
|
965
1483
|
*/
|
|
966
1484
|
async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
|
|
967
1485
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1486
|
+
this.sizeCapReported = false;
|
|
968
1487
|
await this.cameraTask(async () => {
|
|
969
1488
|
const current = this.cameraProducer();
|
|
970
1489
|
const track = current?.track;
|
|
@@ -974,21 +1493,53 @@ export class StreamClient {
|
|
|
974
1493
|
return this.videoQuality;
|
|
975
1494
|
}
|
|
976
1495
|
|
|
977
|
-
/**
|
|
1496
|
+
/**
|
|
1497
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1498
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1499
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1500
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1501
|
+
* the frame rate used.
|
|
1502
|
+
*/
|
|
978
1503
|
async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
|
|
979
1504
|
if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
980
1505
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
981
1506
|
this.frameRateFallback = false;
|
|
982
1507
|
this.frameRateLowSamples = 0;
|
|
1508
|
+
this.sizeCapReported = false;
|
|
1509
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1510
|
+
this.frameRateFallback = true;
|
|
1511
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1512
|
+
return this.videoFrameRate;
|
|
1513
|
+
}
|
|
983
1514
|
await this.cameraTask(async () => {
|
|
984
1515
|
const current = this.cameraProducer();
|
|
985
1516
|
const track = current?.track;
|
|
986
1517
|
if (!current || !track || track.readyState !== 'live') return;
|
|
987
|
-
try { await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
1518
|
+
try { await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } }); }
|
|
988
1519
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
989
1520
|
await this.reproduceCamera(current, track);
|
|
990
1521
|
});
|
|
991
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1522
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1526
|
+
private allows60(): boolean {
|
|
1527
|
+
const permissions = this.mediaPermissions;
|
|
1528
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1533
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1534
|
+
*/
|
|
1535
|
+
private cameraQuality(frameRate: 30 | 60): VideoQuality {
|
|
1536
|
+
const permissions = this.mediaPermissions;
|
|
1537
|
+
if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
|
|
1538
|
+
this.sizeCapReported = true;
|
|
1539
|
+
this.emit('error', new RpcError('video-quality-not-allowed',
|
|
1540
|
+
`${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
|
|
1541
|
+
}
|
|
1542
|
+
return qualityAt(this.publishOptions.quality, frameRate, permissions);
|
|
992
1543
|
}
|
|
993
1544
|
|
|
994
1545
|
/**
|
|
@@ -1031,7 +1582,8 @@ export class StreamClient {
|
|
|
1031
1582
|
const paused = current.paused;
|
|
1032
1583
|
try {
|
|
1033
1584
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1034
|
-
const
|
|
1585
|
+
const frameRate = this.frameRateFor(track);
|
|
1586
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
1035
1587
|
const previousSize = current.track?.getSettings();
|
|
1036
1588
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1037
1589
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -1220,7 +1772,7 @@ export class StreamClient {
|
|
|
1220
1772
|
generation: number,
|
|
1221
1773
|
paused = !track.enabled,
|
|
1222
1774
|
): Promise<Producer> {
|
|
1223
|
-
if (generation !== this.publishGeneration) throw
|
|
1775
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
|
|
1224
1776
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
1225
1777
|
// off. Setting the track to the camera's state first makes the producer
|
|
1226
1778
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -1231,8 +1783,9 @@ export class StreamClient {
|
|
|
1231
1783
|
// the device, so the browser sends every layer asked for (see
|
|
1232
1784
|
// videoEncodings).
|
|
1233
1785
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1234
|
-
|
|
1786
|
+
// The frame rate first: the size the project allows depends on it.
|
|
1235
1787
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1788
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1236
1789
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1237
1790
|
let codecs: RtpCodecCapability[] | undefined;
|
|
1238
1791
|
try {
|
|
@@ -1281,7 +1834,7 @@ export class StreamClient {
|
|
|
1281
1834
|
if (generation !== this.publishGeneration) {
|
|
1282
1835
|
producer.close();
|
|
1283
1836
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1284
|
-
throw
|
|
1837
|
+
throw unpublishedWhile(what);
|
|
1285
1838
|
}
|
|
1286
1839
|
this.producers.set(producer.id, producer);
|
|
1287
1840
|
}
|
|
@@ -1325,7 +1878,7 @@ export class StreamClient {
|
|
|
1325
1878
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1326
1879
|
private reportCodecFallback(message: string): void {
|
|
1327
1880
|
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1328
|
-
this.emit('error', new
|
|
1881
|
+
this.emit('error', new RpcError('codec-fallback', message));
|
|
1329
1882
|
} else if (!this.codecFallbackLogged) {
|
|
1330
1883
|
this.codecFallbackLogged = true;
|
|
1331
1884
|
console.warn(`[voltkit-rtc] ${message}`);
|
|
@@ -1379,7 +1932,7 @@ export class StreamClient {
|
|
|
1379
1932
|
|
|
1380
1933
|
if (!live) {
|
|
1381
1934
|
if (!this.publishConstraints) {
|
|
1382
|
-
this.emit('error', new
|
|
1935
|
+
this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1383
1936
|
return;
|
|
1384
1937
|
}
|
|
1385
1938
|
const ended = stream;
|
|
@@ -1399,7 +1952,7 @@ export class StreamClient {
|
|
|
1399
1952
|
}
|
|
1400
1953
|
if (!stream) return;
|
|
1401
1954
|
|
|
1402
|
-
await this.
|
|
1955
|
+
await this.publishStream(stream);
|
|
1403
1956
|
}
|
|
1404
1957
|
|
|
1405
1958
|
// ------------------------------------------------------------- screen sharing
|
|
@@ -1407,6 +1960,7 @@ export class StreamClient {
|
|
|
1407
1960
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1408
1961
|
async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
|
|
1409
1962
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1963
|
+
if (!this.mediaPermissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1410
1964
|
if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1411
1965
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1412
1966
|
const mode = options.mode ?? 'detail';
|
|
@@ -1423,7 +1977,7 @@ export class StreamClient {
|
|
|
1423
1977
|
});
|
|
1424
1978
|
const video = stream.getVideoTracks()[0];
|
|
1425
1979
|
if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1426
|
-
if (generation !== this.screenGeneration || this.closedByUs) throw
|
|
1980
|
+
if (generation !== this.screenGeneration || this.closedByUs) throw unpublishedWhile('screen');
|
|
1427
1981
|
this.screenStream = stream;
|
|
1428
1982
|
this.screenMode = mode;
|
|
1429
1983
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1432,7 +1986,7 @@ export class StreamClient {
|
|
|
1432
1986
|
this.screenEndedListener = onEnded;
|
|
1433
1987
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1434
1988
|
await this.restoreScreen();
|
|
1435
|
-
if (generation !== this.screenGeneration || video.readyState !== 'live') throw
|
|
1989
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live') throw unpublishedWhile('screen');
|
|
1436
1990
|
this.screenStarted = true;
|
|
1437
1991
|
this.wasPublishing = true;
|
|
1438
1992
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1485,14 +2039,14 @@ export class StreamClient {
|
|
|
1485
2039
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1486
2040
|
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1487
2041
|
// A project's explicit safety restriction also applies to screen video.
|
|
1488
|
-
const cap = topLayerSize(width, height, this.
|
|
2042
|
+
const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1489
2043
|
const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
|
|
1490
2044
|
if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1491
2045
|
const keep = async (producer: Producer) => {
|
|
1492
2046
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1493
2047
|
producer.close();
|
|
1494
2048
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1495
|
-
throw
|
|
2049
|
+
throw unpublishedWhile('screen');
|
|
1496
2050
|
}
|
|
1497
2051
|
this.producers.set(producer.id, producer);
|
|
1498
2052
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1503,7 +2057,12 @@ export class StreamClient {
|
|
|
1503
2057
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1504
2058
|
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1505
2059
|
}));
|
|
1506
|
-
|
|
2060
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
2061
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
2062
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
2063
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
2064
|
+
audio = undefined;
|
|
2065
|
+
}
|
|
1507
2066
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
|
|
1508
2067
|
track: audio, stopTracks: false,
|
|
1509
2068
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
@@ -1511,19 +2070,33 @@ export class StreamClient {
|
|
|
1511
2070
|
}));
|
|
1512
2071
|
}
|
|
1513
2072
|
|
|
2073
|
+
/**
|
|
2074
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
2075
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
2076
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
2077
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
2078
|
+
* the camera or device cannot provide it.
|
|
2079
|
+
*/
|
|
1514
2080
|
private frameRateFor(track: MediaStreamTrack): 30 | 60 {
|
|
1515
2081
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
|
|
2082
|
+
if (!this.allows60()) {
|
|
2083
|
+
this.frameRateFallback = true;
|
|
2084
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
2085
|
+
return 30;
|
|
2086
|
+
}
|
|
1516
2087
|
const captured = track.getSettings().frameRate;
|
|
1517
|
-
if (
|
|
2088
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1518
2089
|
this.frameRateFallback = true;
|
|
1519
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
2090
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1520
2091
|
return 30;
|
|
1521
2092
|
}
|
|
1522
2093
|
return 60;
|
|
1523
2094
|
}
|
|
1524
2095
|
|
|
1525
|
-
private async sampleCamera(baseline: StatsBaseline): Promise<VideoSendStats | null> {
|
|
2096
|
+
private async sampleCamera(baseline: StatsBaseline, isCurrent: () => boolean): Promise<VideoSendStats | null> {
|
|
2097
|
+
const sampledProducer = this.cameraProducer();
|
|
1526
2098
|
const stats = await this.collectVideoStats(baseline);
|
|
2099
|
+
if (!isCurrent() || sampledProducer !== this.cameraProducer()) return null;
|
|
1527
2100
|
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats) return stats;
|
|
1528
2101
|
if (this.cameraProducer()?.paused) return stats;
|
|
1529
2102
|
// The lower simulcast copies intentionally run at 30 fps. During the
|
|
@@ -1534,11 +2107,11 @@ export class StreamClient {
|
|
|
1534
2107
|
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1535
2108
|
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1536
2109
|
if (this.frameRateLowSamples >= 3) {
|
|
1537
|
-
this.frameRateFallback = true;
|
|
1538
2110
|
void this.cameraTask(async () => {
|
|
1539
2111
|
const producer = this.cameraProducer();
|
|
1540
|
-
if (!producer?.track || producer.closed) return;
|
|
1541
|
-
|
|
2112
|
+
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed) return;
|
|
2113
|
+
this.frameRateFallback = true;
|
|
2114
|
+
await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1542
2115
|
await this.reproduceCamera(producer, producer.track);
|
|
1543
2116
|
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1544
2117
|
}).catch((error) => this.emit('error', error as Error));
|
|
@@ -1559,6 +2132,7 @@ export class StreamClient {
|
|
|
1559
2132
|
* the user has not interacted with.
|
|
1560
2133
|
*/
|
|
1561
2134
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
2135
|
+
if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1562
2136
|
const generation = this.publishGeneration;
|
|
1563
2137
|
if (this.music) await this.stopMusic();
|
|
1564
2138
|
const music = new MusicSource(input, options);
|
|
@@ -1567,7 +2141,7 @@ export class StreamClient {
|
|
|
1567
2141
|
// Checked again here: an unpublish() after the producer was kept has
|
|
1568
2142
|
// already closed it, and the music must not be remembered for a
|
|
1569
2143
|
// reconnect to publish again.
|
|
1570
|
-
if (generation !== this.publishGeneration) throw
|
|
2144
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('music');
|
|
1571
2145
|
} catch (err) {
|
|
1572
2146
|
music.close();
|
|
1573
2147
|
throw err;
|
|
@@ -1579,7 +2153,7 @@ export class StreamClient {
|
|
|
1579
2153
|
|
|
1580
2154
|
private async produceMusic(music: MusicSource, generation: number): Promise<void> {
|
|
1581
2155
|
const transport = await this.ensureSendTransport();
|
|
1582
|
-
if (generation !== this.publishGeneration) throw
|
|
2156
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('music');
|
|
1583
2157
|
const producer = await transport.produce({
|
|
1584
2158
|
track: music.track,
|
|
1585
2159
|
stopTracks: false,
|
|
@@ -1642,12 +2216,46 @@ export class StreamClient {
|
|
|
1642
2216
|
}
|
|
1643
2217
|
|
|
1644
2218
|
private async consume(remote: RemoteProducer): Promise<void> {
|
|
1645
|
-
if (!this.device) throw new
|
|
2219
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1646
2220
|
if (this.consumers.has(remote.producerId)) return;
|
|
1647
2221
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1648
2222
|
// consumer and shows you a delayed copy of yourself.
|
|
1649
2223
|
if (remote.identity === this.joinResult?.identity) return;
|
|
2224
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
2225
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
2226
|
+
// asking for a refusal, and say so once per video.
|
|
2227
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
2228
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2229
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
2230
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
2231
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
2232
|
+
this.emit('error', new RpcError('video-subscription-limit',
|
|
2233
|
+
`already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
|
|
2234
|
+
}
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
this.videoSlots.add(remote.producerId);
|
|
2238
|
+
}
|
|
2239
|
+
try {
|
|
2240
|
+
await this.consumeNow(remote);
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
|
|
2243
|
+
throw err;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
1650
2246
|
|
|
2247
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
2248
|
+
private async consumeSkipped(): Promise<void> {
|
|
2249
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
2250
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2251
|
+
if (limit !== null && this.videoSlots.size >= limit) return;
|
|
2252
|
+
this.skippedVideos.delete(producerId);
|
|
2253
|
+
await this.consume(remote);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
private async consumeNow(remote: RemoteProducer): Promise<void> {
|
|
2258
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1651
2259
|
const transport = await this.ensureRecvTransport();
|
|
1652
2260
|
const params = (await this.rpc('consume', {
|
|
1653
2261
|
transportId: transport.id,
|
|
@@ -1679,6 +2287,7 @@ export class StreamClient {
|
|
|
1679
2287
|
const kind = consumer.kind as 'audio' | 'video';
|
|
1680
2288
|
const source = remote.source ?? defaultSource(kind);
|
|
1681
2289
|
this.remoteSources.set(remote.producerId, source);
|
|
2290
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1682
2291
|
this.emit('track', {
|
|
1683
2292
|
identity: remote.identity,
|
|
1684
2293
|
kind,
|
|
@@ -1818,6 +2427,67 @@ export class StreamClient {
|
|
|
1818
2427
|
endRoom(): Promise<unknown> { return this.rpc('endRoom'); }
|
|
1819
2428
|
getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
|
|
1820
2429
|
|
|
2430
|
+
// ------------------------------------------------------- slots and metadata
|
|
2431
|
+
//
|
|
2432
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2433
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2434
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2435
|
+
// seed a board on join and follow claimChanged.
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2439
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2440
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2441
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2442
|
+
*/
|
|
2443
|
+
async claim(key: string, identity?: string): Promise<void> {
|
|
2444
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2448
|
+
async release(key: string): Promise<void> {
|
|
2449
|
+
await this.rpc('release', { key });
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2454
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2455
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2456
|
+
*/
|
|
2457
|
+
async blockSlot(key: string): Promise<void> {
|
|
2458
|
+
await this.rpc('blockSlot', { key });
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
/** Unlock a slot. Moderators only. */
|
|
2462
|
+
async unblockSlot(key: string): Promise<void> {
|
|
2463
|
+
await this.rpc('unblockSlot', { key });
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2467
|
+
async setMetadata(metadata: Record<string, unknown>): Promise<void> {
|
|
2468
|
+
await this.rpc('setMetadata', { metadata });
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2473
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2474
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2475
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2476
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2477
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2478
|
+
*/
|
|
2479
|
+
async setRoomMetadata(
|
|
2480
|
+
metadata: Record<string, unknown>,
|
|
2481
|
+
options: { replace?: boolean; requireFree?: string[] } = {},
|
|
2482
|
+
): Promise<unknown> {
|
|
2483
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2484
|
+
metadata,
|
|
2485
|
+
...(options.replace ? { replace: true } : {}),
|
|
2486
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2487
|
+
})) as { metadata?: unknown } | undefined;
|
|
2488
|
+
return res?.metadata ?? null;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
1821
2491
|
// ------------------------------------------------------------------- chat
|
|
1822
2492
|
//
|
|
1823
2493
|
// The room's chat rides this same socket. Someone on a chat-only socket
|