@voltkit-rtc/client 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -2
- package/README.md +236 -11
- package/dist/cjs/broadcast.d.ts.map +1 -1
- package/dist/cjs/broadcast.js +16 -7
- package/dist/cjs/broadcast.js.map +1 -1
- package/dist/cjs/chat.d.ts +11 -9
- package/dist/cjs/chat.d.ts.map +1 -1
- package/dist/cjs/chat.js +56 -19
- 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 +320 -16
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +728 -123
- 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/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/broadcast.d.ts.map +1 -1
- package/dist/esm/broadcast.js +16 -7
- package/dist/esm/broadcast.js.map +1 -1
- package/dist/esm/chat.d.ts +11 -9
- package/dist/esm/chat.d.ts.map +1 -1
- package/dist/esm/chat.js +56 -19
- 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 +320 -16
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +707 -114
- 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/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/broadcast.ts +16 -7
- package/src/chat.ts +44 -18
- package/src/errors.ts +162 -0
- package/src/index.ts +842 -114
- package/src/media-permissions.ts +220 -0
- package/src/network.ts +212 -0
- 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;
|
|
@@ -305,8 +436,30 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
|
|
|
305
436
|
*/
|
|
306
437
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
307
438
|
|
|
439
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
440
|
+
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
441
|
+
|
|
308
442
|
/** 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
|
|
443
|
+
const unpublishedWhile = (what: string) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
447
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
448
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
449
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
450
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
451
|
+
* audio go out as they always did.
|
|
452
|
+
*/
|
|
453
|
+
function declareAudioQuality(rtpParameters: ms.RtpParameters, appData: Record<string, unknown>): ms.RtpParameters {
|
|
454
|
+
const quality = appData.audioQuality;
|
|
455
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd')) return rtpParameters;
|
|
456
|
+
const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
|
|
457
|
+
return {
|
|
458
|
+
...rtpParameters,
|
|
459
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
460
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
310
463
|
|
|
311
464
|
export class StreamClient {
|
|
312
465
|
private ws: WebSocket | null = null;
|
|
@@ -360,7 +513,15 @@ export class StreamClient {
|
|
|
360
513
|
* the full picture.
|
|
361
514
|
*/
|
|
362
515
|
private readonly preferredLayers = new Map<string, PreferredLayers>();
|
|
516
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
517
|
+
private readonly endRoomWaiters = new Set<() => void>();
|
|
363
518
|
private closedByUs = false;
|
|
519
|
+
/**
|
|
520
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
521
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
522
|
+
* after its next await and stops, so a closed session never comes back.
|
|
523
|
+
*/
|
|
524
|
+
private lifecycle = 0;
|
|
364
525
|
private reconnecting = false;
|
|
365
526
|
private reconnectAttempt = 0;
|
|
366
527
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -384,9 +545,33 @@ export class StreamClient {
|
|
|
384
545
|
undefined,
|
|
385
546
|
() => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; },
|
|
386
547
|
);
|
|
548
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
549
|
+
private state: ConnectionState = 'idle';
|
|
550
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
551
|
+
private tokenExpiry: number | null;
|
|
552
|
+
private tokenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
553
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
554
|
+
private readonly remoteInfo = new Map<string, { identity: string; kind: 'audio' | 'video'; paused: boolean }>();
|
|
555
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
556
|
+
private readonly skippedVideos = new Map<string, RemoteProducer>();
|
|
557
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
558
|
+
private readonly videoSlots = new Set<string>();
|
|
559
|
+
/** The quality the microphone producer was made with. */
|
|
560
|
+
private micQuality: AudioQuality = 'std';
|
|
561
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
562
|
+
private micQueue: Promise<unknown> = Promise.resolve();
|
|
563
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
564
|
+
private audioCapReported = false;
|
|
565
|
+
private sizeCapReported = false;
|
|
566
|
+
private readonly networkWatch = new NetworkQualityWatch(
|
|
567
|
+
() => this.transportStats(),
|
|
568
|
+
() => this.producers.size > 0,
|
|
569
|
+
(quality) => this.emit('networkQuality', quality),
|
|
570
|
+
);
|
|
387
571
|
|
|
388
|
-
constructor(private readonly wsUrl: string, token: string) {
|
|
572
|
+
constructor(private readonly wsUrl: string, token: string, private readonly options: ClientOptions = {}) {
|
|
389
573
|
this.token = token;
|
|
574
|
+
this.tokenExpiry = tokenExpiresAt(token);
|
|
390
575
|
}
|
|
391
576
|
|
|
392
577
|
on<K extends keyof ClientEvents>(event: K, fn: ClientEvents[K]): () => void {
|
|
@@ -396,14 +581,44 @@ export class StreamClient {
|
|
|
396
581
|
this.listeners.set(event, set);
|
|
397
582
|
}
|
|
398
583
|
set.add(fn as Handler);
|
|
399
|
-
// videoLimited
|
|
584
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
585
|
+
// they run only while heard.
|
|
400
586
|
if (event === 'videoLimited') this.syncLimitWatch();
|
|
587
|
+
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
401
588
|
return () => {
|
|
402
589
|
set!.delete(fn as Handler);
|
|
403
590
|
if (event === 'videoLimited') this.syncLimitWatch();
|
|
591
|
+
if (event === 'networkQuality') this.syncNetworkWatch();
|
|
404
592
|
};
|
|
405
593
|
}
|
|
406
594
|
|
|
595
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
596
|
+
get connectionState(): ConnectionState {
|
|
597
|
+
return this.state;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private setState(next: ConnectionState): void {
|
|
601
|
+
if (next === this.state) return;
|
|
602
|
+
const previous = this.state;
|
|
603
|
+
this.state = next;
|
|
604
|
+
if (next === 'closed') this.stopTimers();
|
|
605
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Over for good: the state, then the event that says why. */
|
|
609
|
+
private finish(reason: ClosedReason): void {
|
|
610
|
+
this.closedByUs = true;
|
|
611
|
+
this.lifecycle += 1;
|
|
612
|
+
this.setState('closed');
|
|
613
|
+
this.emit('closed', reason);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private stopTimers(): void {
|
|
617
|
+
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
618
|
+
this.tokenTimer = null;
|
|
619
|
+
this.networkWatch.set(false);
|
|
620
|
+
}
|
|
621
|
+
|
|
407
622
|
private listenerCount(event: keyof ClientEvents): number {
|
|
408
623
|
return this.listeners.get(event)?.size ?? 0;
|
|
409
624
|
}
|
|
@@ -421,7 +636,10 @@ export class StreamClient {
|
|
|
421
636
|
// ------------------------------------------------------------- connection
|
|
422
637
|
|
|
423
638
|
async connect(): Promise<JoinResult> {
|
|
639
|
+
const lifecycle = this.lifecycle;
|
|
640
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
424
641
|
let result: JoinResult;
|
|
642
|
+
if (this.state === 'idle' || this.state === 'closed') this.setState('connecting');
|
|
425
643
|
try {
|
|
426
644
|
await this.openSocket();
|
|
427
645
|
result = (await this.rpc('join', { token: this.token })) as JoinResult;
|
|
@@ -429,11 +647,17 @@ export class StreamClient {
|
|
|
429
647
|
|
|
430
648
|
this.device = this.createDevice();
|
|
431
649
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
650
|
+
// close() ran while the Device loaded: going on would set the state
|
|
651
|
+
// back to connected, and emit joined, after closed.
|
|
652
|
+
if (closed()) throw closedWhileConnecting();
|
|
432
653
|
} catch (err) {
|
|
433
654
|
// A session that never got going: the app has the error. Left open,
|
|
434
655
|
// the socket would be closed by the server's join deadline instead, and
|
|
435
656
|
// that close would start reconnecting a session that never began.
|
|
436
|
-
if (!this.joinedOnce)
|
|
657
|
+
if (!this.joinedOnce) {
|
|
658
|
+
this.detachSocket();
|
|
659
|
+
if (this.state === 'connecting') this.setState('idle');
|
|
660
|
+
}
|
|
437
661
|
throw err;
|
|
438
662
|
}
|
|
439
663
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -442,11 +666,15 @@ export class StreamClient {
|
|
|
442
666
|
// retried about twice a second, forever.
|
|
443
667
|
this.reconnectAttempt = 0;
|
|
444
668
|
this.joinedOnce = true;
|
|
669
|
+
this.skippedVideos.clear();
|
|
670
|
+
this.setState('connected');
|
|
671
|
+
this.scheduleTokenExpiring();
|
|
445
672
|
|
|
446
673
|
// A role or mute can change while the socket is away. The fresh roster
|
|
447
674
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
448
675
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
449
676
|
await this.unpublish();
|
|
677
|
+
if (closed()) throw closedWhileConnecting();
|
|
450
678
|
}
|
|
451
679
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
452
680
|
if (own?.muted) this.applyLocalModeration('audio');
|
|
@@ -467,22 +695,31 @@ export class StreamClient {
|
|
|
467
695
|
for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
|
|
468
696
|
|
|
469
697
|
// Subscribe to everything already publishing before we announce readiness.
|
|
698
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
470
699
|
for (const p of result.producers) {
|
|
471
|
-
|
|
700
|
+
if (closed()) throw closedWhileConnecting();
|
|
701
|
+
await this.consume(p).catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
472
702
|
}
|
|
473
703
|
|
|
474
704
|
// Restore our own broadcast. This is what makes an origin failover
|
|
475
705
|
// survivable for the host: it reconnects onto the new origin and the
|
|
476
706
|
// stream comes back on its own.
|
|
477
707
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
478
|
-
await this.republish().catch((err) => this.emit('error', err as Error));
|
|
708
|
+
await this.republish().catch((err) => { if (!closed()) this.emit('error', err as Error); });
|
|
479
709
|
}
|
|
710
|
+
if (closed()) throw closedWhileConnecting();
|
|
711
|
+
this.syncNetworkWatch();
|
|
480
712
|
return result;
|
|
481
713
|
}
|
|
482
714
|
|
|
483
|
-
/**
|
|
715
|
+
/**
|
|
716
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
717
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
718
|
+
* WebRTC, can stand one in.
|
|
719
|
+
*/
|
|
484
720
|
private createDevice(): Device {
|
|
485
|
-
|
|
721
|
+
const { handlerName } = this.options;
|
|
722
|
+
return handlerName ? new Device({ handlerName }) : new Device();
|
|
486
723
|
}
|
|
487
724
|
|
|
488
725
|
private openSocket(): Promise<void> {
|
|
@@ -492,16 +729,14 @@ export class StreamClient {
|
|
|
492
729
|
this.detachSocket();
|
|
493
730
|
|
|
494
731
|
return new Promise((resolve, reject) => {
|
|
495
|
-
const
|
|
496
|
-
url.searchParams.set('token', this.token);
|
|
497
|
-
const ws = new WebSocket(url.toString());
|
|
732
|
+
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
498
733
|
this.ws = ws;
|
|
499
734
|
|
|
500
735
|
const onOpen = () => {
|
|
501
736
|
this.emit('connected');
|
|
502
737
|
resolve();
|
|
503
738
|
};
|
|
504
|
-
const onError = () => reject(new
|
|
739
|
+
const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
505
740
|
|
|
506
741
|
ws.addEventListener('open', onOpen, { once: true });
|
|
507
742
|
ws.addEventListener('error', onError, { once: true });
|
|
@@ -513,49 +748,60 @@ export class StreamClient {
|
|
|
513
748
|
// Only the socket we currently consider ours may drive reconnection.
|
|
514
749
|
if (this.ws !== ws) return;
|
|
515
750
|
this.ws = null;
|
|
516
|
-
this.failAllPending(new
|
|
751
|
+
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
517
752
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
518
753
|
|
|
519
754
|
if (this.closedByUs) return;
|
|
520
755
|
if (ev.code === CLOSE_REPLACED) {
|
|
521
756
|
// Another session for this identity is live. Reconnecting would
|
|
522
757
|
// displace it, and it would displace us right back, forever.
|
|
523
|
-
this.emit('error', new
|
|
524
|
-
this.closedByUs = true;
|
|
758
|
+
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
525
759
|
// The producers outlive this close until the app calls close(); the
|
|
526
760
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
527
761
|
this.limitWatch.reset();
|
|
528
|
-
this.
|
|
762
|
+
this.finish('replaced');
|
|
529
763
|
return;
|
|
530
764
|
}
|
|
531
765
|
if (ev.code === CLOSE_ENDED) {
|
|
532
766
|
// Normally roomClosed has already ended the session; this covers a
|
|
533
767
|
// close that arrived without it.
|
|
534
|
-
this.closedByUs = true;
|
|
535
768
|
this.limitWatch.reset();
|
|
536
|
-
this.
|
|
769
|
+
this.finish('room-closed');
|
|
537
770
|
return;
|
|
538
771
|
}
|
|
539
772
|
// Before a first successful join the app is still waiting on
|
|
540
773
|
// connect(), which reports the failure; there is nothing to restore.
|
|
541
774
|
if (!this.joinedOnce) return;
|
|
775
|
+
this.setState('reconnecting');
|
|
776
|
+
this.networkWatch.set(false);
|
|
542
777
|
void this.scheduleReconnect();
|
|
543
778
|
});
|
|
544
779
|
});
|
|
545
780
|
}
|
|
546
781
|
|
|
547
|
-
private detachSocket(): void {
|
|
782
|
+
private detachSocket(reason = 'superseded'): void {
|
|
548
783
|
const ws = this.ws;
|
|
549
784
|
this.ws = null;
|
|
550
785
|
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
551
786
|
try {
|
|
552
|
-
ws.close(1000,
|
|
787
|
+
ws.close(1000, reason);
|
|
553
788
|
} catch {
|
|
554
789
|
/* already closing */
|
|
555
790
|
}
|
|
556
791
|
}
|
|
557
792
|
}
|
|
558
793
|
|
|
794
|
+
/**
|
|
795
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
796
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
797
|
+
* and the session would otherwise report closed with it still attached and
|
|
798
|
+
* still sending.
|
|
799
|
+
*/
|
|
800
|
+
private releaseSocket(): void {
|
|
801
|
+
this.detachSocket('gave up');
|
|
802
|
+
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
803
|
+
}
|
|
804
|
+
|
|
559
805
|
/**
|
|
560
806
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
561
807
|
* promotions move you to a different node. Always re-ask the orchestrator
|
|
@@ -571,9 +817,9 @@ export class StreamClient {
|
|
|
571
817
|
while (!this.closedByUs) {
|
|
572
818
|
this.reconnectAttempt += 1;
|
|
573
819
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
574
|
-
this.emit('error', new
|
|
575
|
-
this.
|
|
576
|
-
this.
|
|
820
|
+
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
821
|
+
this.releaseSocket();
|
|
822
|
+
this.finish('gave-up');
|
|
577
823
|
return;
|
|
578
824
|
}
|
|
579
825
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -588,13 +834,16 @@ export class StreamClient {
|
|
|
588
834
|
// for good, with no `closed`.
|
|
589
835
|
if (this.ws) return;
|
|
590
836
|
} catch (err) {
|
|
837
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
838
|
+
// server -- and that has been reported: nothing follows closed.
|
|
839
|
+
if (this.closedByUs) return;
|
|
591
840
|
this.emit('error', err as Error);
|
|
592
|
-
// Stop on failures a retry cannot resolve
|
|
593
|
-
// ended produces one error per
|
|
594
|
-
// whatever actually went wrong.
|
|
595
|
-
if (err instanceof RpcError &&
|
|
596
|
-
this.
|
|
597
|
-
this.
|
|
841
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
842
|
+
// codes). Hammering a room that has ended produces one error per
|
|
843
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
844
|
+
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
845
|
+
this.releaseSocket();
|
|
846
|
+
this.finish('gave-up');
|
|
598
847
|
return;
|
|
599
848
|
}
|
|
600
849
|
}
|
|
@@ -610,6 +859,7 @@ export class StreamClient {
|
|
|
610
859
|
// others have already reported themselves and set closedByUs.
|
|
611
860
|
const wasOpen = !this.closedByUs;
|
|
612
861
|
this.closedByUs = true;
|
|
862
|
+
this.lifecycle += 1;
|
|
613
863
|
this.publishGeneration += 1;
|
|
614
864
|
this.wasPublishing = false;
|
|
615
865
|
this.teardownMedia();
|
|
@@ -621,6 +871,7 @@ export class StreamClient {
|
|
|
621
871
|
this.music = null;
|
|
622
872
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
623
873
|
this.limitWatch.reset();
|
|
874
|
+
this.skippedVideos.clear();
|
|
624
875
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
625
876
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
626
877
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -628,12 +879,20 @@ export class StreamClient {
|
|
|
628
879
|
const ws = this.ws;
|
|
629
880
|
this.ws = null;
|
|
630
881
|
ws?.close(1000, 'client closed');
|
|
882
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
883
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
884
|
+
// wait out its 15 s timeout.
|
|
885
|
+
this.failAllPending(new RpcError('not-connected', 'the session was closed'));
|
|
886
|
+
this.setState('closed');
|
|
631
887
|
if (wasOpen) this.emit('closed', 'left');
|
|
632
888
|
}
|
|
633
889
|
|
|
634
890
|
private teardownMedia(): void {
|
|
635
891
|
for (const c of this.consumers.values()) c.close();
|
|
636
892
|
this.consumers.clear();
|
|
893
|
+
this.remoteInfo.clear();
|
|
894
|
+
this.skippedVideos.clear();
|
|
895
|
+
this.videoSlots.clear();
|
|
637
896
|
for (const p of this.producers.values()) p.close();
|
|
638
897
|
this.producers.clear();
|
|
639
898
|
this.musicProducerId = null;
|
|
@@ -649,25 +908,142 @@ export class StreamClient {
|
|
|
649
908
|
this.sendTransportPending = null;
|
|
650
909
|
this.recvTransportPending = null;
|
|
651
910
|
this.device = null;
|
|
911
|
+
this.syncNetworkWatch();
|
|
652
912
|
}
|
|
653
913
|
|
|
654
|
-
/**
|
|
914
|
+
/**
|
|
915
|
+
* Everything currently known about the room, for rendering: the join
|
|
916
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
917
|
+
* metadata, slots and room metadata follow the events.
|
|
918
|
+
*/
|
|
655
919
|
get info(): JoinResult | null {
|
|
656
920
|
return this.joinResult;
|
|
657
921
|
}
|
|
658
922
|
|
|
923
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
924
|
+
get participants(): ParticipantInfo[] {
|
|
925
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Every track this client is receiving now. */
|
|
929
|
+
get remoteTracks(): RemoteTrack[] {
|
|
930
|
+
const out: RemoteTrack[] = [];
|
|
931
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
932
|
+
const info = this.remoteInfo.get(producerId);
|
|
933
|
+
if (!info || consumer.closed) continue;
|
|
934
|
+
out.push({
|
|
935
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
936
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* What this project lets this client publish: from the join result's
|
|
944
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
945
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
946
|
+
* or caps anything else with the server's own codes.
|
|
947
|
+
*/
|
|
948
|
+
get mediaPermissions(): MediaPermissions {
|
|
949
|
+
return resolvePermissions(this.joinResult);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
953
|
+
get billingMode(): 'legacy' | 'wallet' | null {
|
|
954
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ------------------------------------------------------------ token refresh
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
961
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
962
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
963
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
964
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
965
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
966
|
+
*/
|
|
967
|
+
async updateToken(token: string): Promise<{ expiresAt: number | null }> {
|
|
968
|
+
if (typeof token !== 'string' || token.trim() === '') throw new TypeError('updateToken needs a token');
|
|
969
|
+
if (this.state === 'closed') throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
970
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
971
|
+
try {
|
|
972
|
+
const res = (await this.rpc('refreshToken', { token })) as { expiresAt?: unknown } | undefined;
|
|
973
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
974
|
+
this.setToken(token, at);
|
|
975
|
+
return { expiresAt: this.tokenExpiry };
|
|
976
|
+
} catch (err) {
|
|
977
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
978
|
+
// during the call is reconnecting: either way keep the token for the
|
|
979
|
+
// next join, which checks it.
|
|
980
|
+
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected')) throw err;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
this.setToken(token, null);
|
|
984
|
+
return { expiresAt: this.tokenExpiry };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
988
|
+
private setToken(token: string, expiresAt: number | null): void {
|
|
989
|
+
this.token = token;
|
|
990
|
+
this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
|
|
991
|
+
this.scheduleTokenExpiring();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
996
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
997
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
998
|
+
*/
|
|
999
|
+
private scheduleTokenExpiring(): void {
|
|
1000
|
+
if (this.tokenTimer) clearTimeout(this.tokenTimer);
|
|
1001
|
+
this.tokenTimer = null;
|
|
1002
|
+
const expiresAt = this.tokenExpiry;
|
|
1003
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed') return;
|
|
1004
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
1005
|
+
const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
1006
|
+
this.tokenTimer = setTimeout(() => {
|
|
1007
|
+
this.tokenTimer = null;
|
|
1008
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt) return;
|
|
1009
|
+
if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
1010
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
1014
|
+
}, delay);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ------------------------------------------------------------ network quality
|
|
1018
|
+
|
|
1019
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
1020
|
+
private syncNetworkWatch(): void {
|
|
1021
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
1025
|
+
private async transportStats(): Promise<Map<string, Iterable<Record<string, unknown>>>> {
|
|
1026
|
+
const out = new Map<string, Iterable<Record<string, unknown>>>();
|
|
1027
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
1028
|
+
if (!transport || transport.closed) continue;
|
|
1029
|
+
const report = await transport.getStats();
|
|
1030
|
+
out.set(transport.id, report.values() as Iterable<Record<string, unknown>>);
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
659
1035
|
// -------------------------------------------------------------------- rpc
|
|
660
1036
|
|
|
661
1037
|
private rpc(method: string, params: unknown = {}): Promise<unknown> {
|
|
662
1038
|
return new Promise((resolve, reject) => {
|
|
663
1039
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
664
|
-
reject(new
|
|
1040
|
+
reject(new RpcError('not-connected', 'socket is not open'));
|
|
665
1041
|
return;
|
|
666
1042
|
}
|
|
667
1043
|
const id = this.nextId++;
|
|
668
1044
|
const timer = setTimeout(() => {
|
|
669
1045
|
this.pending.delete(id);
|
|
670
|
-
reject(new
|
|
1046
|
+
reject(new RpcError('timeout', `${method} timed out`));
|
|
671
1047
|
}, RPC_TIMEOUT_MS);
|
|
672
1048
|
this.pending.set(id, { resolve, reject, timer });
|
|
673
1049
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -689,6 +1065,9 @@ export class StreamClient {
|
|
|
689
1065
|
} catch {
|
|
690
1066
|
return;
|
|
691
1067
|
}
|
|
1068
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
1069
|
+
// ignored like any other frame that is not a message.
|
|
1070
|
+
if (typeof msg !== 'object' || msg === null) return;
|
|
692
1071
|
|
|
693
1072
|
if (typeof msg.id === 'number') {
|
|
694
1073
|
const p = this.pending.get(msg.id);
|
|
@@ -697,8 +1076,8 @@ export class StreamClient {
|
|
|
697
1076
|
this.pending.delete(msg.id);
|
|
698
1077
|
if (msg.ok) p.resolve(msg.result);
|
|
699
1078
|
else {
|
|
700
|
-
const e = msg.error as { code?: string; message?: string } | undefined;
|
|
701
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
1079
|
+
const e = msg.error as { code?: string; message?: string; data?: unknown } | undefined;
|
|
1080
|
+
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
702
1081
|
}
|
|
703
1082
|
return;
|
|
704
1083
|
}
|
|
@@ -717,16 +1096,24 @@ export class StreamClient {
|
|
|
717
1096
|
this.preferredLayers.delete(producerId);
|
|
718
1097
|
this.receiveQualities.delete(producerId);
|
|
719
1098
|
this.remoteVideoLayers.delete(producerId);
|
|
1099
|
+
this.skippedVideos.delete(producerId);
|
|
720
1100
|
const consumer = this.consumers.get(producerId);
|
|
721
1101
|
if (!consumer) break; // never consumed it, or already cleaned up
|
|
1102
|
+
this.videoSlots.delete(producerId);
|
|
722
1103
|
consumer.close();
|
|
723
1104
|
this.consumers.delete(producerId);
|
|
724
1105
|
this.remoteSources.delete(producerId);
|
|
1106
|
+
this.remoteInfo.delete(producerId);
|
|
725
1107
|
this.emit('trackEnded', { producerId, identity });
|
|
1108
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
1109
|
+
// waiting takes it.
|
|
1110
|
+
if (consumer.kind === 'video') void this.consumeSkipped().catch((err) => this.emit('error', err as Error));
|
|
726
1111
|
break;
|
|
727
1112
|
}
|
|
728
1113
|
case 'producerPaused': {
|
|
729
1114
|
const p = data as { producerId: string; identity: string; kind: 'audio' | 'video'; paused: boolean };
|
|
1115
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
1116
|
+
if (info) info.paused = Boolean(p.paused);
|
|
730
1117
|
this.emit('trackMuted', {
|
|
731
1118
|
identity: p.identity,
|
|
732
1119
|
kind: p.kind,
|
|
@@ -736,26 +1123,40 @@ export class StreamClient {
|
|
|
736
1123
|
});
|
|
737
1124
|
break;
|
|
738
1125
|
}
|
|
739
|
-
case 'participantJoined':
|
|
740
|
-
|
|
1126
|
+
case 'participantJoined': {
|
|
1127
|
+
const p = data as ParticipantInfo;
|
|
1128
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
1129
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
1130
|
+
this.emit('participantJoined', p);
|
|
741
1131
|
break;
|
|
1132
|
+
}
|
|
742
1133
|
case 'participantMuted': {
|
|
743
1134
|
const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
|
|
744
1135
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
745
|
-
|
|
1136
|
+
this.updateRoster((list) => list.map((p) =>
|
|
746
1137
|
p.identity !== e.identity ? p : { ...p,
|
|
747
1138
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
748
1139
|
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
749
|
-
});
|
|
1140
|
+
}));
|
|
750
1141
|
this.emit('participantMuted', e);
|
|
751
1142
|
break;
|
|
752
1143
|
}
|
|
753
|
-
case '
|
|
754
|
-
|
|
1144
|
+
case 'participantUpdated': {
|
|
1145
|
+
const e = data as { identity: string; metadata?: unknown };
|
|
1146
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
1147
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
755
1148
|
break;
|
|
1149
|
+
}
|
|
1150
|
+
case 'participantLeft': {
|
|
1151
|
+
const e = data as { identity: string; reason?: string };
|
|
1152
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
1153
|
+
this.emit('participantLeft', e);
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
756
1156
|
case 'roleChanged': {
|
|
757
1157
|
const e = data as { identity: string; role: Role };
|
|
758
1158
|
if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
|
|
1159
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
759
1160
|
this.emit('roleChanged', e);
|
|
760
1161
|
// Demoted: the server has already closed our producers. Release the
|
|
761
1162
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -766,9 +1167,13 @@ export class StreamClient {
|
|
|
766
1167
|
break;
|
|
767
1168
|
}
|
|
768
1169
|
case 'handRaised':
|
|
769
|
-
case 'handLowered':
|
|
1170
|
+
case 'handLowered': {
|
|
1171
|
+
const e = data as { identity: string; handRaised: boolean; metadata?: Record<string, unknown> };
|
|
1172
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
1173
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
770
1174
|
this.emit('handRaised', data as { identity: string; handRaised: boolean });
|
|
771
1175
|
break;
|
|
1176
|
+
}
|
|
772
1177
|
case 'forceMuted': {
|
|
773
1178
|
// The server has already paused that kind (an audio mute includes
|
|
774
1179
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -781,18 +1186,30 @@ export class StreamClient {
|
|
|
781
1186
|
this.emit('forceMuted', e);
|
|
782
1187
|
break;
|
|
783
1188
|
}
|
|
1189
|
+
case 'claimChanged': {
|
|
1190
|
+
const e = data as { key: string; identity?: string | null; state: ClaimChange['state']; reason?: string };
|
|
1191
|
+
this.applyClaim(e);
|
|
1192
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
case 'roomMetadataChanged': {
|
|
1196
|
+
const e = data as { metadata?: unknown; by?: string };
|
|
1197
|
+
if (this.joinResult) this.joinResult.metadata = e.metadata;
|
|
1198
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
784
1201
|
case 'tokenRefreshed': {
|
|
785
1202
|
// A room outlives its join token many times over. The server hands us a
|
|
786
1203
|
// fresh one periodically so a reconnect at any point still works --
|
|
787
1204
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
788
|
-
const e = data as { token?: string; role?: Role };
|
|
789
|
-
if (e.token) this.token
|
|
1205
|
+
const e = data as { token?: string; role?: Role; expiresAt?: number };
|
|
1206
|
+
if (e.token) this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
790
1207
|
break;
|
|
791
1208
|
}
|
|
792
1209
|
case 'mustReconnect': {
|
|
793
1210
|
const e = data as { reason: string; token?: string; retry: boolean };
|
|
794
1211
|
// A promotion hands us a fresh token carrying the new role.
|
|
795
|
-
if (e.token) this.
|
|
1212
|
+
if (e.token) this.setToken(e.token, null);
|
|
796
1213
|
this.emit('mustReconnect', e);
|
|
797
1214
|
if (e.retry) {
|
|
798
1215
|
this.teardownMedia();
|
|
@@ -808,16 +1225,26 @@ export class StreamClient {
|
|
|
808
1225
|
case 'usageLimit':
|
|
809
1226
|
this.emit('usageLimit', d as never);
|
|
810
1227
|
break;
|
|
1228
|
+
case 'walletLow': {
|
|
1229
|
+
const e = data as { remainingSeconds?: unknown; cleared?: unknown };
|
|
1230
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
1231
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
811
1234
|
case 'roomClosed':
|
|
812
1235
|
this.closedByUs = true;
|
|
813
1236
|
this.emit('roomClosed', d as never);
|
|
814
|
-
this.
|
|
1237
|
+
this.finish('room-closed');
|
|
1238
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
1239
|
+
// answers succeeded.
|
|
1240
|
+
for (const ended of this.endRoomWaiters) ended();
|
|
1241
|
+
this.endRoomWaiters.clear();
|
|
815
1242
|
this.close();
|
|
816
1243
|
break;
|
|
817
1244
|
case 'kicked':
|
|
818
1245
|
this.closedByUs = true;
|
|
819
1246
|
this.emit('kicked', d as never);
|
|
820
|
-
this.
|
|
1247
|
+
this.finish('kicked');
|
|
821
1248
|
this.close();
|
|
822
1249
|
break;
|
|
823
1250
|
case 'message':
|
|
@@ -837,6 +1264,23 @@ export class StreamClient {
|
|
|
837
1264
|
}
|
|
838
1265
|
}
|
|
839
1266
|
|
|
1267
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
1268
|
+
private updateRoster(change: (list: ParticipantInfo[]) => ParticipantInfo[]): void {
|
|
1269
|
+
if (this.joinResult) this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
1273
|
+
private applyClaim(e: { key: string; identity?: string | null; state: string }): void {
|
|
1274
|
+
const join = this.joinResult;
|
|
1275
|
+
if (!join) return;
|
|
1276
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
1277
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
1278
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity) claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
1279
|
+
if (e.state === 'blocked') blocked = [...blocked, e.key];
|
|
1280
|
+
join.claims = claims;
|
|
1281
|
+
join.blockedSlots = blocked;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
840
1284
|
// ------------------------------------------------------------------ media
|
|
841
1285
|
|
|
842
1286
|
private ensureSendTransport(): Promise<Transport> {
|
|
@@ -849,7 +1293,7 @@ export class StreamClient {
|
|
|
849
1293
|
}
|
|
850
1294
|
|
|
851
1295
|
private async createSendTransport(): Promise<Transport> {
|
|
852
|
-
if (!this.device) throw new
|
|
1296
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
853
1297
|
|
|
854
1298
|
const params = (await this.rpc('createTransport', { direction: 'send' })) as TransportOptions;
|
|
855
1299
|
const transport = this.device.createSendTransport({
|
|
@@ -863,7 +1307,10 @@ export class StreamClient {
|
|
|
863
1307
|
.catch(fail);
|
|
864
1308
|
});
|
|
865
1309
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
866
|
-
this.rpc('produce', {
|
|
1310
|
+
this.rpc('produce', {
|
|
1311
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
1312
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
1313
|
+
})
|
|
867
1314
|
.then((res) => ok({ id: (res as { producerId: string }).producerId }))
|
|
868
1315
|
.catch(fail);
|
|
869
1316
|
});
|
|
@@ -882,7 +1329,7 @@ export class StreamClient {
|
|
|
882
1329
|
}
|
|
883
1330
|
|
|
884
1331
|
private async createRecvTransport(): Promise<Transport> {
|
|
885
|
-
if (!this.device) throw new
|
|
1332
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
886
1333
|
|
|
887
1334
|
const params = (await this.rpc('createTransport', { direction: 'recv' })) as TransportOptions;
|
|
888
1335
|
const transport = this.device.createRecvTransport({
|
|
@@ -912,11 +1359,24 @@ export class StreamClient {
|
|
|
912
1359
|
* kept for a reconnect's republish.
|
|
913
1360
|
*/
|
|
914
1361
|
async publish(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
1362
|
+
// A kind the project switched off is refused here, before anything is
|
|
1363
|
+
// produced, with the code the controller would answer with.
|
|
1364
|
+
const permissions = this.mediaPermissions;
|
|
1365
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
1366
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1367
|
+
}
|
|
1368
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
1369
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1370
|
+
}
|
|
1371
|
+
await this.publishStream(stream, constraints, options);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
private async publishStream(stream: MediaStream, constraints?: MediaStreamConstraints, options?: PublishOptions): Promise<void> {
|
|
915
1375
|
const generation = this.publishGeneration;
|
|
916
1376
|
const transport = await this.ensureSendTransport();
|
|
917
1377
|
// unpublish() ran first: this publish is over before it began, and must
|
|
918
1378
|
// leave nothing behind that a reconnect would publish again.
|
|
919
|
-
if (generation !== this.publishGeneration) throw
|
|
1379
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('stream');
|
|
920
1380
|
this.localStream = stream;
|
|
921
1381
|
this.wasPublishing = true;
|
|
922
1382
|
if (constraints) this.publishConstraints = constraints;
|
|
@@ -924,39 +1384,144 @@ export class StreamClient {
|
|
|
924
1384
|
this.publishOptions = { ...options };
|
|
925
1385
|
this.frameRateFallback = false;
|
|
926
1386
|
this.frameRateLowSamples = 0;
|
|
1387
|
+
this.audioCapReported = false;
|
|
1388
|
+
this.sizeCapReported = false;
|
|
927
1389
|
}
|
|
1390
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
1391
|
+
// what is still allowed goes out, and the rest is reported.
|
|
1392
|
+
const permissions = this.mediaPermissions;
|
|
928
1393
|
|
|
929
1394
|
const audio = stream.getAudioTracks()[0];
|
|
930
|
-
if (audio) {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
// every producer, and a stopped track cannot be republished.
|
|
935
|
-
stopTracks: false,
|
|
936
|
-
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
937
|
-
appData: { source: 'mic' },
|
|
938
|
-
});
|
|
939
|
-
await this.keepProducer(producer, generation, 'microphone');
|
|
940
|
-
// A microphone handed over switched off, or muted before a reconnect.
|
|
941
|
-
await this.pauseOnServer(producer);
|
|
1395
|
+
if (audio && !permissions.audio) {
|
|
1396
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
1397
|
+
} else if (audio) {
|
|
1398
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
942
1399
|
}
|
|
943
1400
|
|
|
944
1401
|
const video = stream.getVideoTracks()[0];
|
|
945
|
-
if (video) {
|
|
1402
|
+
if (video && !permissions.video) {
|
|
1403
|
+
this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
1404
|
+
} else if (video) {
|
|
946
1405
|
// The track is read when the camera's turn comes: one handed to
|
|
947
1406
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
948
1407
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
949
1408
|
}
|
|
950
1409
|
}
|
|
951
1410
|
|
|
1411
|
+
// ------------------------------------------------------------ audio quality
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The microphone's audio quality: what it is being sent at while
|
|
1415
|
+
* published, else what the next publish will use, after the project's limit.
|
|
1416
|
+
*/
|
|
1417
|
+
get audioQuality(): AudioQuality {
|
|
1418
|
+
if (this.micProducer()) return this.micQuality;
|
|
1419
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
1424
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
1425
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
1426
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
1427
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
1428
|
+
* only. Resolves to the quality in use.
|
|
1429
|
+
*/
|
|
1430
|
+
async setAudioQuality(quality: AudioQuality): Promise<AudioQuality> {
|
|
1431
|
+
if (quality !== 'std' && quality !== 'hd') throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
1432
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
1433
|
+
throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
1434
|
+
}
|
|
1435
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
1436
|
+
this.audioCapReported = false;
|
|
1437
|
+
await this.micTask(async () => {
|
|
1438
|
+
const current = this.micProducer();
|
|
1439
|
+
const track = current?.track;
|
|
1440
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality) return;
|
|
1441
|
+
await this.reproduceMic(current, track);
|
|
1442
|
+
});
|
|
1443
|
+
return this.audioQuality;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
private micProducer(): Producer | undefined {
|
|
1447
|
+
for (const p of this.producers.values()) {
|
|
1448
|
+
if (p.kind === 'audio' && (p.appData as { source?: string }).source === 'mic') return p;
|
|
1449
|
+
}
|
|
1450
|
+
return undefined;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private micTask<T>(task: () => Promise<T>): Promise<T> {
|
|
1454
|
+
const run = this.micQueue.then(task, task);
|
|
1455
|
+
this.micQueue = run.catch(() => undefined);
|
|
1456
|
+
return run;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1460
|
+
private audioQualityFor(): AudioQuality {
|
|
1461
|
+
if (this.publishOptions.audioQuality !== 'hd') return 'std';
|
|
1462
|
+
if (this.mediaPermissions.audioQualityMax === 'hd') return 'hd';
|
|
1463
|
+
if (!this.audioCapReported) {
|
|
1464
|
+
this.audioCapReported = true;
|
|
1465
|
+
this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1466
|
+
}
|
|
1467
|
+
return 'std';
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1472
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1473
|
+
* paused, on both ends.
|
|
1474
|
+
*/
|
|
1475
|
+
private async produceMic(transport: Transport, track: MediaStreamTrack, generation: number): Promise<Producer> {
|
|
1476
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('microphone');
|
|
1477
|
+
const quality = this.audioQualityFor();
|
|
1478
|
+
const settings = AUDIO_SETTINGS[quality];
|
|
1479
|
+
const producer = await transport.produce({
|
|
1480
|
+
track,
|
|
1481
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1482
|
+
// every producer, and a stopped track cannot be republished.
|
|
1483
|
+
stopTracks: false,
|
|
1484
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1485
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1486
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1487
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1488
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1489
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1490
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1491
|
+
});
|
|
1492
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1493
|
+
this.micQuality = quality;
|
|
1494
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1495
|
+
await this.pauseOnServer(producer);
|
|
1496
|
+
return producer;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1500
|
+
private async reproduceMic(current: Producer, track: MediaStreamTrack): Promise<Producer> {
|
|
1501
|
+
const generation = this.publishGeneration;
|
|
1502
|
+
current.close();
|
|
1503
|
+
this.producers.delete(current.id);
|
|
1504
|
+
// The server allows one producer per source: the old one goes first.
|
|
1505
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1506
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
952
1509
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
953
1510
|
get videoPolicy(): VideoPolicy {
|
|
954
1511
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
955
1512
|
}
|
|
956
1513
|
|
|
957
|
-
/**
|
|
1514
|
+
/**
|
|
1515
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1516
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1517
|
+
*/
|
|
958
1518
|
get videoQuality(): VideoQuality {
|
|
959
|
-
return
|
|
1519
|
+
return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1523
|
+
get videoFrameRate(): 30 | 60 {
|
|
1524
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
960
1525
|
}
|
|
961
1526
|
|
|
962
1527
|
/**
|
|
@@ -967,6 +1532,7 @@ export class StreamClient {
|
|
|
967
1532
|
*/
|
|
968
1533
|
async setVideoQuality(quality: VideoQuality): Promise<VideoQuality> {
|
|
969
1534
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1535
|
+
this.sizeCapReported = false;
|
|
970
1536
|
await this.cameraTask(async () => {
|
|
971
1537
|
const current = this.cameraProducer();
|
|
972
1538
|
const track = current?.track;
|
|
@@ -976,12 +1542,24 @@ export class StreamClient {
|
|
|
976
1542
|
return this.videoQuality;
|
|
977
1543
|
}
|
|
978
1544
|
|
|
979
|
-
/**
|
|
1545
|
+
/**
|
|
1546
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1547
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1548
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1549
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1550
|
+
* the frame rate used.
|
|
1551
|
+
*/
|
|
980
1552
|
async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
|
|
981
1553
|
if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
982
1554
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
983
1555
|
this.frameRateFallback = false;
|
|
984
1556
|
this.frameRateLowSamples = 0;
|
|
1557
|
+
this.sizeCapReported = false;
|
|
1558
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1559
|
+
this.frameRateFallback = true;
|
|
1560
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1561
|
+
return this.videoFrameRate;
|
|
1562
|
+
}
|
|
985
1563
|
await this.cameraTask(async () => {
|
|
986
1564
|
const current = this.cameraProducer();
|
|
987
1565
|
const track = current?.track;
|
|
@@ -990,7 +1568,27 @@ export class StreamClient {
|
|
|
990
1568
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
991
1569
|
await this.reproduceCamera(current, track);
|
|
992
1570
|
});
|
|
993
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1571
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1575
|
+
private allows60(): boolean {
|
|
1576
|
+
const permissions = this.mediaPermissions;
|
|
1577
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1582
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1583
|
+
*/
|
|
1584
|
+
private cameraQuality(frameRate: 30 | 60): VideoQuality {
|
|
1585
|
+
const permissions = this.mediaPermissions;
|
|
1586
|
+
if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
|
|
1587
|
+
this.sizeCapReported = true;
|
|
1588
|
+
this.emit('error', new RpcError('video-quality-not-allowed',
|
|
1589
|
+
`${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
|
|
1590
|
+
}
|
|
1591
|
+
return qualityAt(this.publishOptions.quality, frameRate, permissions);
|
|
994
1592
|
}
|
|
995
1593
|
|
|
996
1594
|
/**
|
|
@@ -1033,7 +1631,8 @@ export class StreamClient {
|
|
|
1033
1631
|
const paused = current.paused;
|
|
1034
1632
|
try {
|
|
1035
1633
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1036
|
-
const
|
|
1634
|
+
const frameRate = this.frameRateFor(track);
|
|
1635
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
1037
1636
|
const previousSize = current.track?.getSettings();
|
|
1038
1637
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1039
1638
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -1222,7 +1821,7 @@ export class StreamClient {
|
|
|
1222
1821
|
generation: number,
|
|
1223
1822
|
paused = !track.enabled,
|
|
1224
1823
|
): Promise<Producer> {
|
|
1225
|
-
if (generation !== this.publishGeneration) throw
|
|
1824
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('camera');
|
|
1226
1825
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
1227
1826
|
// off. Setting the track to the camera's state first makes the producer
|
|
1228
1827
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -1233,8 +1832,9 @@ export class StreamClient {
|
|
|
1233
1832
|
// the device, so the browser sends every layer asked for (see
|
|
1234
1833
|
// videoEncodings).
|
|
1235
1834
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1236
|
-
|
|
1835
|
+
// The frame rate first: the size the project allows depends on it.
|
|
1237
1836
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1837
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1238
1838
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1239
1839
|
let codecs: RtpCodecCapability[] | undefined;
|
|
1240
1840
|
try {
|
|
@@ -1283,7 +1883,7 @@ export class StreamClient {
|
|
|
1283
1883
|
if (generation !== this.publishGeneration) {
|
|
1284
1884
|
producer.close();
|
|
1285
1885
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1286
|
-
throw
|
|
1886
|
+
throw unpublishedWhile(what);
|
|
1287
1887
|
}
|
|
1288
1888
|
this.producers.set(producer.id, producer);
|
|
1289
1889
|
}
|
|
@@ -1327,7 +1927,7 @@ export class StreamClient {
|
|
|
1327
1927
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1328
1928
|
private reportCodecFallback(message: string): void {
|
|
1329
1929
|
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1330
|
-
this.emit('error', new
|
|
1930
|
+
this.emit('error', new RpcError('codec-fallback', message));
|
|
1331
1931
|
} else if (!this.codecFallbackLogged) {
|
|
1332
1932
|
this.codecFallbackLogged = true;
|
|
1333
1933
|
console.warn(`[voltkit-rtc] ${message}`);
|
|
@@ -1381,7 +1981,7 @@ export class StreamClient {
|
|
|
1381
1981
|
|
|
1382
1982
|
if (!live) {
|
|
1383
1983
|
if (!this.publishConstraints) {
|
|
1384
|
-
this.emit('error', new
|
|
1984
|
+
this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1385
1985
|
return;
|
|
1386
1986
|
}
|
|
1387
1987
|
const ended = stream;
|
|
@@ -1401,7 +2001,7 @@ export class StreamClient {
|
|
|
1401
2001
|
}
|
|
1402
2002
|
if (!stream) return;
|
|
1403
2003
|
|
|
1404
|
-
await this.
|
|
2004
|
+
await this.publishStream(stream);
|
|
1405
2005
|
}
|
|
1406
2006
|
|
|
1407
2007
|
// ------------------------------------------------------------- screen sharing
|
|
@@ -1409,6 +2009,7 @@ export class StreamClient {
|
|
|
1409
2009
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1410
2010
|
async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
|
|
1411
2011
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
2012
|
+
if (!this.mediaPermissions.video) throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1412
2013
|
if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1413
2014
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1414
2015
|
const mode = options.mode ?? 'detail';
|
|
@@ -1425,7 +2026,7 @@ export class StreamClient {
|
|
|
1425
2026
|
});
|
|
1426
2027
|
const video = stream.getVideoTracks()[0];
|
|
1427
2028
|
if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1428
|
-
if (generation !== this.screenGeneration || this.closedByUs) throw
|
|
2029
|
+
if (generation !== this.screenGeneration || this.closedByUs) throw unpublishedWhile('screen');
|
|
1429
2030
|
this.screenStream = stream;
|
|
1430
2031
|
this.screenMode = mode;
|
|
1431
2032
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1434,7 +2035,7 @@ export class StreamClient {
|
|
|
1434
2035
|
this.screenEndedListener = onEnded;
|
|
1435
2036
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1436
2037
|
await this.restoreScreen();
|
|
1437
|
-
if (generation !== this.screenGeneration || video.readyState !== 'live') throw
|
|
2038
|
+
if (generation !== this.screenGeneration || video.readyState !== 'live') throw unpublishedWhile('screen');
|
|
1438
2039
|
this.screenStarted = true;
|
|
1439
2040
|
this.wasPublishing = true;
|
|
1440
2041
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1487,14 +2088,14 @@ export class StreamClient {
|
|
|
1487
2088
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1488
2089
|
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1489
2090
|
// A project's explicit safety restriction also applies to screen video.
|
|
1490
|
-
const cap = topLayerSize(width, height, this.
|
|
2091
|
+
const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1491
2092
|
const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
|
|
1492
2093
|
if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
1493
2094
|
const keep = async (producer: Producer) => {
|
|
1494
2095
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1495
2096
|
producer.close();
|
|
1496
2097
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1497
|
-
throw
|
|
2098
|
+
throw unpublishedWhile('screen');
|
|
1498
2099
|
}
|
|
1499
2100
|
this.producers.set(producer.id, producer);
|
|
1500
2101
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1505,7 +2106,12 @@ export class StreamClient {
|
|
|
1505
2106
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1506
2107
|
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1507
2108
|
}));
|
|
1508
|
-
|
|
2109
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
2110
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
2111
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
2112
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
2113
|
+
audio = undefined;
|
|
2114
|
+
}
|
|
1509
2115
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
|
|
1510
2116
|
track: audio, stopTracks: false,
|
|
1511
2117
|
codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
|
|
@@ -1513,12 +2119,24 @@ export class StreamClient {
|
|
|
1513
2119
|
}));
|
|
1514
2120
|
}
|
|
1515
2121
|
|
|
2122
|
+
/**
|
|
2123
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
2124
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
2125
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
2126
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
2127
|
+
* the camera or device cannot provide it.
|
|
2128
|
+
*/
|
|
1516
2129
|
private frameRateFor(track: MediaStreamTrack): 30 | 60 {
|
|
1517
2130
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
|
|
2131
|
+
if (!this.allows60()) {
|
|
2132
|
+
this.frameRateFallback = true;
|
|
2133
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
2134
|
+
return 30;
|
|
2135
|
+
}
|
|
1518
2136
|
const captured = track.getSettings().frameRate;
|
|
1519
|
-
if (
|
|
2137
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1520
2138
|
this.frameRateFallback = true;
|
|
1521
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
2139
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1522
2140
|
return 30;
|
|
1523
2141
|
}
|
|
1524
2142
|
return 60;
|
|
@@ -1563,6 +2181,7 @@ export class StreamClient {
|
|
|
1563
2181
|
* the user has not interacted with.
|
|
1564
2182
|
*/
|
|
1565
2183
|
async publishMusic(input: MusicInput, options?: MusicOptions): Promise<void> {
|
|
2184
|
+
if (!this.mediaPermissions.audio) throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1566
2185
|
const generation = this.publishGeneration;
|
|
1567
2186
|
if (this.music) await this.stopMusic();
|
|
1568
2187
|
const music = new MusicSource(input, options);
|
|
@@ -1571,7 +2190,7 @@ export class StreamClient {
|
|
|
1571
2190
|
// Checked again here: an unpublish() after the producer was kept has
|
|
1572
2191
|
// already closed it, and the music must not be remembered for a
|
|
1573
2192
|
// reconnect to publish again.
|
|
1574
|
-
if (generation !== this.publishGeneration) throw
|
|
2193
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('music');
|
|
1575
2194
|
} catch (err) {
|
|
1576
2195
|
music.close();
|
|
1577
2196
|
throw err;
|
|
@@ -1583,7 +2202,7 @@ export class StreamClient {
|
|
|
1583
2202
|
|
|
1584
2203
|
private async produceMusic(music: MusicSource, generation: number): Promise<void> {
|
|
1585
2204
|
const transport = await this.ensureSendTransport();
|
|
1586
|
-
if (generation !== this.publishGeneration) throw
|
|
2205
|
+
if (generation !== this.publishGeneration) throw unpublishedWhile('music');
|
|
1587
2206
|
const producer = await transport.produce({
|
|
1588
2207
|
track: music.track,
|
|
1589
2208
|
stopTracks: false,
|
|
@@ -1646,12 +2265,46 @@ export class StreamClient {
|
|
|
1646
2265
|
}
|
|
1647
2266
|
|
|
1648
2267
|
private async consume(remote: RemoteProducer): Promise<void> {
|
|
1649
|
-
if (!this.device) throw new
|
|
2268
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1650
2269
|
if (this.consumers.has(remote.producerId)) return;
|
|
1651
2270
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1652
2271
|
// consumer and shows you a delayed copy of yourself.
|
|
1653
2272
|
if (remote.identity === this.joinResult?.identity) return;
|
|
2273
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
2274
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
2275
|
+
// asking for a refusal, and say so once per video.
|
|
2276
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
2277
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2278
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
2279
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
2280
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
2281
|
+
this.emit('error', new RpcError('video-subscription-limit',
|
|
2282
|
+
`already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
|
|
2283
|
+
}
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
this.videoSlots.add(remote.producerId);
|
|
2287
|
+
}
|
|
2288
|
+
try {
|
|
2289
|
+
await this.consumeNow(remote);
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
if (!this.consumers.has(remote.producerId)) this.videoSlots.delete(remote.producerId);
|
|
2292
|
+
throw err;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
1654
2295
|
|
|
2296
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
2297
|
+
private async consumeSkipped(): Promise<void> {
|
|
2298
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
2299
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
2300
|
+
if (limit !== null && this.videoSlots.size >= limit) return;
|
|
2301
|
+
this.skippedVideos.delete(producerId);
|
|
2302
|
+
await this.consume(remote);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
private async consumeNow(remote: RemoteProducer): Promise<void> {
|
|
2307
|
+
if (!this.device) throw new RpcError('not-connected', 'not connected');
|
|
1655
2308
|
const transport = await this.ensureRecvTransport();
|
|
1656
2309
|
const params = (await this.rpc('consume', {
|
|
1657
2310
|
transportId: transport.id,
|
|
@@ -1683,6 +2336,7 @@ export class StreamClient {
|
|
|
1683
2336
|
const kind = consumer.kind as 'audio' | 'video';
|
|
1684
2337
|
const source = remote.source ?? defaultSource(kind);
|
|
1685
2338
|
this.remoteSources.set(remote.producerId, source);
|
|
2339
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1686
2340
|
this.emit('track', {
|
|
1687
2341
|
identity: remote.identity,
|
|
1688
2342
|
kind,
|
|
@@ -1819,9 +2473,83 @@ export class StreamClient {
|
|
|
1819
2473
|
}
|
|
1820
2474
|
promote(identity: string, role: Role): Promise<unknown> { return this.rpc('promote', { identity, role }); }
|
|
1821
2475
|
kick(identity: string, reason?: string): Promise<unknown> { return this.rpc('kick', { identity, reason }); }
|
|
1822
|
-
|
|
2476
|
+
/**
|
|
2477
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2478
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2479
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2480
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2481
|
+
* server refuses, or the connection drops with neither.
|
|
2482
|
+
*/
|
|
2483
|
+
endRoom(): Promise<unknown> {
|
|
2484
|
+
return new Promise((resolve, reject) => {
|
|
2485
|
+
const ended = () => resolve({ ok: true });
|
|
2486
|
+
this.endRoomWaiters.add(ended);
|
|
2487
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
1823
2490
|
getRoomState(): Promise<unknown> { return this.rpc('getRoomState'); }
|
|
1824
2491
|
|
|
2492
|
+
// ------------------------------------------------------- slots and metadata
|
|
2493
|
+
//
|
|
2494
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2495
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2496
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2497
|
+
// seed a board on join and follow claimChanged.
|
|
2498
|
+
|
|
2499
|
+
/**
|
|
2500
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2501
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2502
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2503
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2504
|
+
*/
|
|
2505
|
+
async claim(key: string, identity?: string): Promise<void> {
|
|
2506
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2510
|
+
async release(key: string): Promise<void> {
|
|
2511
|
+
await this.rpc('release', { key });
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2516
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2517
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2518
|
+
*/
|
|
2519
|
+
async blockSlot(key: string): Promise<void> {
|
|
2520
|
+
await this.rpc('blockSlot', { key });
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/** Unlock a slot. Moderators only. */
|
|
2524
|
+
async unblockSlot(key: string): Promise<void> {
|
|
2525
|
+
await this.rpc('unblockSlot', { key });
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2529
|
+
async setMetadata(metadata: Record<string, unknown>): Promise<void> {
|
|
2530
|
+
await this.rpc('setMetadata', { metadata });
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2535
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2536
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2537
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2538
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2539
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2540
|
+
*/
|
|
2541
|
+
async setRoomMetadata(
|
|
2542
|
+
metadata: Record<string, unknown>,
|
|
2543
|
+
options: { replace?: boolean; requireFree?: string[] } = {},
|
|
2544
|
+
): Promise<unknown> {
|
|
2545
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2546
|
+
metadata,
|
|
2547
|
+
...(options.replace ? { replace: true } : {}),
|
|
2548
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2549
|
+
})) as { metadata?: unknown } | undefined;
|
|
2550
|
+
return res?.metadata ?? null;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
1825
2553
|
// ------------------------------------------------------------------- chat
|
|
1826
2554
|
//
|
|
1827
2555
|
// The room's chat rides this same socket. Someone on a chat-only socket
|