@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/dist/esm/index.js
CHANGED
|
@@ -7,6 +7,10 @@ import { CameraRecording } from './recording.js';
|
|
|
7
7
|
* setup, consumer resume ordering and reconnection, each slightly wrong.
|
|
8
8
|
*/
|
|
9
9
|
import { Device } from 'mediasoup-client';
|
|
10
|
+
import { isTerminalError, RpcError } from './errors.js';
|
|
11
|
+
import { above60fpsCap, AUDIO_SETTINGS, qualityAt, resolvePermissions, } from './media-permissions.js';
|
|
12
|
+
import { NetworkQualityWatch } from './network.js';
|
|
13
|
+
import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
10
14
|
import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
|
|
11
15
|
import { MusicSource } from './music.js';
|
|
12
16
|
import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, describeVideoLayers, receiveSpatialLayer, screenEncodings, topLayerSize, videoEncodings, } from './quality.js';
|
|
@@ -15,36 +19,29 @@ export { CameraRecording, RecordingUploader, RecordingError } from './recording.
|
|
|
15
19
|
export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
|
|
16
20
|
export { AudioMixer, MusicSource } from './music.js';
|
|
17
21
|
export { ChatClient, ChatRpcError } from './chat.js';
|
|
22
|
+
export { RpcError, ERROR_CODES, errorInfo, isTerminalError } from './errors.js';
|
|
23
|
+
export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePermissions } from './media-permissions.js';
|
|
24
|
+
export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
|
|
25
|
+
export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
18
26
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
27
|
+
/** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
|
|
28
|
+
export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
|
|
19
29
|
const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const TERMINAL_JOIN_ERRORS = new Set([
|
|
36
|
-
'no-room',
|
|
37
|
-
'room-ended',
|
|
38
|
-
'wrong-tenant',
|
|
39
|
-
'bad-token',
|
|
40
|
-
'no-token',
|
|
41
|
-
'forbidden',
|
|
42
|
-
// The account cannot use the service until it is topped up or restored.
|
|
43
|
-
'quota-exhausted',
|
|
44
|
-
'daily-limit-reached',
|
|
45
|
-
'monthly-limit-reached',
|
|
46
|
-
'tenant-suspended',
|
|
47
|
-
]);
|
|
30
|
+
// Every event a StreamClient emits. Typed against ClientEvents, so adding an
|
|
31
|
+
// event without listing it here (or listing one that does not exist) fails to
|
|
32
|
+
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
33
|
+
const EVENT_NAMES = {
|
|
34
|
+
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
35
|
+
track: true, trackEnded: true, trackMuted: true,
|
|
36
|
+
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
37
|
+
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
38
|
+
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
39
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
40
|
+
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
41
|
+
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
42
|
+
};
|
|
43
|
+
/** The names of every event `StreamClient.on` accepts. */
|
|
44
|
+
export const CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES));
|
|
48
45
|
const RPC_TIMEOUT_MS = 15_000;
|
|
49
46
|
const MAX_RECONNECT_ATTEMPTS = 8;
|
|
50
47
|
/** Server close code meaning "another socket took over this identity". */
|
|
@@ -59,9 +56,29 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
59
56
|
*/
|
|
60
57
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
61
58
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
62
|
-
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published
|
|
59
|
+
const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
60
|
+
/**
|
|
61
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
62
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
63
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
64
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
65
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
66
|
+
* audio go out as they always did.
|
|
67
|
+
*/
|
|
68
|
+
function declareAudioQuality(rtpParameters, appData) {
|
|
69
|
+
const quality = appData.audioQuality;
|
|
70
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
|
|
71
|
+
return rtpParameters;
|
|
72
|
+
const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
|
|
73
|
+
return {
|
|
74
|
+
...rtpParameters,
|
|
75
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
76
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
63
79
|
export class StreamClient {
|
|
64
80
|
wsUrl;
|
|
81
|
+
options;
|
|
65
82
|
ws = null;
|
|
66
83
|
device = null;
|
|
67
84
|
sendTransport = null;
|
|
@@ -131,10 +148,31 @@ export class StreamClient {
|
|
|
131
148
|
codecFallbackLogged = false;
|
|
132
149
|
sendStatsBaseline = new Map();
|
|
133
150
|
recvStatsBaseline = new Map();
|
|
134
|
-
limitWatch = new VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
|
|
135
|
-
|
|
151
|
+
limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
152
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
153
|
+
state = 'idle';
|
|
154
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
155
|
+
tokenExpiry;
|
|
156
|
+
tokenTimer = null;
|
|
157
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
158
|
+
remoteInfo = new Map();
|
|
159
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
160
|
+
skippedVideos = new Map();
|
|
161
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
162
|
+
videoSlots = new Set();
|
|
163
|
+
/** The quality the microphone producer was made with. */
|
|
164
|
+
micQuality = 'std';
|
|
165
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
166
|
+
micQueue = Promise.resolve();
|
|
167
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
168
|
+
audioCapReported = false;
|
|
169
|
+
sizeCapReported = false;
|
|
170
|
+
networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
171
|
+
constructor(wsUrl, token, options = {}) {
|
|
136
172
|
this.wsUrl = wsUrl;
|
|
173
|
+
this.options = options;
|
|
137
174
|
this.token = token;
|
|
175
|
+
this.tokenExpiry = tokenExpiresAt(token);
|
|
138
176
|
}
|
|
139
177
|
on(event, fn) {
|
|
140
178
|
let set = this.listeners.get(event);
|
|
@@ -143,15 +181,45 @@ export class StreamClient {
|
|
|
143
181
|
this.listeners.set(event, set);
|
|
144
182
|
}
|
|
145
183
|
set.add(fn);
|
|
146
|
-
// videoLimited
|
|
184
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
185
|
+
// they run only while heard.
|
|
147
186
|
if (event === 'videoLimited')
|
|
148
187
|
this.syncLimitWatch();
|
|
188
|
+
if (event === 'networkQuality')
|
|
189
|
+
this.syncNetworkWatch();
|
|
149
190
|
return () => {
|
|
150
191
|
set.delete(fn);
|
|
151
192
|
if (event === 'videoLimited')
|
|
152
193
|
this.syncLimitWatch();
|
|
194
|
+
if (event === 'networkQuality')
|
|
195
|
+
this.syncNetworkWatch();
|
|
153
196
|
};
|
|
154
197
|
}
|
|
198
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
199
|
+
get connectionState() {
|
|
200
|
+
return this.state;
|
|
201
|
+
}
|
|
202
|
+
setState(next) {
|
|
203
|
+
if (next === this.state)
|
|
204
|
+
return;
|
|
205
|
+
const previous = this.state;
|
|
206
|
+
this.state = next;
|
|
207
|
+
if (next === 'closed')
|
|
208
|
+
this.stopTimers();
|
|
209
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
210
|
+
}
|
|
211
|
+
/** Over for good: the state, then the event that says why. */
|
|
212
|
+
finish(reason) {
|
|
213
|
+
this.closedByUs = true;
|
|
214
|
+
this.setState('closed');
|
|
215
|
+
this.emit('closed', reason);
|
|
216
|
+
}
|
|
217
|
+
stopTimers() {
|
|
218
|
+
if (this.tokenTimer)
|
|
219
|
+
clearTimeout(this.tokenTimer);
|
|
220
|
+
this.tokenTimer = null;
|
|
221
|
+
this.networkWatch.set(false);
|
|
222
|
+
}
|
|
155
223
|
listenerCount(event) {
|
|
156
224
|
return this.listeners.get(event)?.size ?? 0;
|
|
157
225
|
}
|
|
@@ -168,6 +236,8 @@ export class StreamClient {
|
|
|
168
236
|
// ------------------------------------------------------------- connection
|
|
169
237
|
async connect() {
|
|
170
238
|
let result;
|
|
239
|
+
if (this.state === 'idle' || this.state === 'closed')
|
|
240
|
+
this.setState('connecting');
|
|
171
241
|
try {
|
|
172
242
|
await this.openSocket();
|
|
173
243
|
result = (await this.rpc('join', { token: this.token }));
|
|
@@ -179,8 +249,11 @@ export class StreamClient {
|
|
|
179
249
|
// A session that never got going: the app has the error. Left open,
|
|
180
250
|
// the socket would be closed by the server's join deadline instead, and
|
|
181
251
|
// that close would start reconnecting a session that never began.
|
|
182
|
-
if (!this.joinedOnce)
|
|
252
|
+
if (!this.joinedOnce) {
|
|
183
253
|
this.detachSocket();
|
|
254
|
+
if (this.state === 'connecting')
|
|
255
|
+
this.setState('idle');
|
|
256
|
+
}
|
|
184
257
|
throw err;
|
|
185
258
|
}
|
|
186
259
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -189,6 +262,9 @@ export class StreamClient {
|
|
|
189
262
|
// retried about twice a second, forever.
|
|
190
263
|
this.reconnectAttempt = 0;
|
|
191
264
|
this.joinedOnce = true;
|
|
265
|
+
this.skippedVideos.clear();
|
|
266
|
+
this.setState('connected');
|
|
267
|
+
this.scheduleTokenExpiring();
|
|
192
268
|
// A role or mute can change while the socket is away. The fresh roster
|
|
193
269
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
194
270
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
@@ -223,11 +299,17 @@ export class StreamClient {
|
|
|
223
299
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
224
300
|
await this.republish().catch((err) => this.emit('error', err));
|
|
225
301
|
}
|
|
302
|
+
this.syncNetworkWatch();
|
|
226
303
|
return result;
|
|
227
304
|
}
|
|
228
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
307
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
308
|
+
* WebRTC, can stand one in.
|
|
309
|
+
*/
|
|
229
310
|
createDevice() {
|
|
230
|
-
|
|
311
|
+
const { handlerName } = this.options;
|
|
312
|
+
return handlerName ? new Device({ handlerName }) : new Device();
|
|
231
313
|
}
|
|
232
314
|
openSocket() {
|
|
233
315
|
// Abandon any previous socket first. Its close handler checks identity
|
|
@@ -235,15 +317,13 @@ export class StreamClient {
|
|
|
235
317
|
// reconnect -- which is what let multiple reconnect chains pile up.
|
|
236
318
|
this.detachSocket();
|
|
237
319
|
return new Promise((resolve, reject) => {
|
|
238
|
-
const
|
|
239
|
-
url.searchParams.set('token', this.token);
|
|
240
|
-
const ws = new WebSocket(url.toString());
|
|
320
|
+
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
241
321
|
this.ws = ws;
|
|
242
322
|
const onOpen = () => {
|
|
243
323
|
this.emit('connected');
|
|
244
324
|
resolve();
|
|
245
325
|
};
|
|
246
|
-
const onError = () => reject(new
|
|
326
|
+
const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
247
327
|
ws.addEventListener('open', onOpen, { once: true });
|
|
248
328
|
ws.addEventListener('error', onError, { once: true });
|
|
249
329
|
ws.addEventListener('message', (ev) => {
|
|
@@ -256,33 +336,33 @@ export class StreamClient {
|
|
|
256
336
|
if (this.ws !== ws)
|
|
257
337
|
return;
|
|
258
338
|
this.ws = null;
|
|
259
|
-
this.failAllPending(new
|
|
339
|
+
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
260
340
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
261
341
|
if (this.closedByUs)
|
|
262
342
|
return;
|
|
263
343
|
if (ev.code === CLOSE_REPLACED) {
|
|
264
344
|
// Another session for this identity is live. Reconnecting would
|
|
265
345
|
// displace it, and it would displace us right back, forever.
|
|
266
|
-
this.emit('error', new
|
|
267
|
-
this.closedByUs = true;
|
|
346
|
+
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
268
347
|
// The producers outlive this close until the app calls close(); the
|
|
269
348
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
270
349
|
this.limitWatch.reset();
|
|
271
|
-
this.
|
|
350
|
+
this.finish('replaced');
|
|
272
351
|
return;
|
|
273
352
|
}
|
|
274
353
|
if (ev.code === CLOSE_ENDED) {
|
|
275
354
|
// Normally roomClosed has already ended the session; this covers a
|
|
276
355
|
// close that arrived without it.
|
|
277
|
-
this.closedByUs = true;
|
|
278
356
|
this.limitWatch.reset();
|
|
279
|
-
this.
|
|
357
|
+
this.finish('room-closed');
|
|
280
358
|
return;
|
|
281
359
|
}
|
|
282
360
|
// Before a first successful join the app is still waiting on
|
|
283
361
|
// connect(), which reports the failure; there is nothing to restore.
|
|
284
362
|
if (!this.joinedOnce)
|
|
285
363
|
return;
|
|
364
|
+
this.setState('reconnecting');
|
|
365
|
+
this.networkWatch.set(false);
|
|
286
366
|
void this.scheduleReconnect();
|
|
287
367
|
});
|
|
288
368
|
});
|
|
@@ -315,9 +395,8 @@ export class StreamClient {
|
|
|
315
395
|
while (!this.closedByUs) {
|
|
316
396
|
this.reconnectAttempt += 1;
|
|
317
397
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
318
|
-
this.emit('error', new
|
|
319
|
-
this.
|
|
320
|
-
this.emit('closed', 'gave-up');
|
|
398
|
+
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
399
|
+
this.finish('gave-up');
|
|
321
400
|
return;
|
|
322
401
|
}
|
|
323
402
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -336,12 +415,11 @@ export class StreamClient {
|
|
|
336
415
|
}
|
|
337
416
|
catch (err) {
|
|
338
417
|
this.emit('error', err);
|
|
339
|
-
// Stop on failures a retry cannot resolve
|
|
340
|
-
// ended produces one error per
|
|
341
|
-
// whatever actually went wrong.
|
|
342
|
-
if (err instanceof RpcError &&
|
|
343
|
-
this.
|
|
344
|
-
this.emit('closed', 'gave-up');
|
|
418
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
419
|
+
// codes). Hammering a room that has ended produces one error per
|
|
420
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
421
|
+
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
422
|
+
this.finish('gave-up');
|
|
345
423
|
return;
|
|
346
424
|
}
|
|
347
425
|
}
|
|
@@ -368,6 +446,7 @@ export class StreamClient {
|
|
|
368
446
|
this.music = null;
|
|
369
447
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
370
448
|
this.limitWatch.reset();
|
|
449
|
+
this.skippedVideos.clear();
|
|
371
450
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
372
451
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
373
452
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -375,6 +454,7 @@ export class StreamClient {
|
|
|
375
454
|
const ws = this.ws;
|
|
376
455
|
this.ws = null;
|
|
377
456
|
ws?.close(1000, 'client closed');
|
|
457
|
+
this.setState('closed');
|
|
378
458
|
if (wasOpen)
|
|
379
459
|
this.emit('closed', 'left');
|
|
380
460
|
}
|
|
@@ -382,6 +462,9 @@ export class StreamClient {
|
|
|
382
462
|
for (const c of this.consumers.values())
|
|
383
463
|
c.close();
|
|
384
464
|
this.consumers.clear();
|
|
465
|
+
this.remoteInfo.clear();
|
|
466
|
+
this.skippedVideos.clear();
|
|
467
|
+
this.videoSlots.clear();
|
|
385
468
|
for (const p of this.producers.values())
|
|
386
469
|
p.close();
|
|
387
470
|
this.producers.clear();
|
|
@@ -398,22 +481,137 @@ export class StreamClient {
|
|
|
398
481
|
this.sendTransportPending = null;
|
|
399
482
|
this.recvTransportPending = null;
|
|
400
483
|
this.device = null;
|
|
484
|
+
this.syncNetworkWatch();
|
|
401
485
|
}
|
|
402
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* Everything currently known about the room, for rendering: the join
|
|
488
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
489
|
+
* metadata, slots and room metadata follow the events.
|
|
490
|
+
*/
|
|
403
491
|
get info() {
|
|
404
492
|
return this.joinResult;
|
|
405
493
|
}
|
|
494
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
495
|
+
get participants() {
|
|
496
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
497
|
+
}
|
|
498
|
+
/** Every track this client is receiving now. */
|
|
499
|
+
get remoteTracks() {
|
|
500
|
+
const out = [];
|
|
501
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
502
|
+
const info = this.remoteInfo.get(producerId);
|
|
503
|
+
if (!info || consumer.closed)
|
|
504
|
+
continue;
|
|
505
|
+
out.push({
|
|
506
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
507
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* What this project lets this client publish: from the join result's
|
|
514
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
515
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
516
|
+
* or caps anything else with the server's own codes.
|
|
517
|
+
*/
|
|
518
|
+
get mediaPermissions() {
|
|
519
|
+
return resolvePermissions(this.joinResult);
|
|
520
|
+
}
|
|
521
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
522
|
+
get billingMode() {
|
|
523
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
524
|
+
}
|
|
525
|
+
// ------------------------------------------------------------ token refresh
|
|
526
|
+
/**
|
|
527
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
528
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
529
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
530
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
531
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
532
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
533
|
+
*/
|
|
534
|
+
async updateToken(token) {
|
|
535
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
536
|
+
throw new TypeError('updateToken needs a token');
|
|
537
|
+
if (this.state === 'closed')
|
|
538
|
+
throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
539
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
540
|
+
try {
|
|
541
|
+
const res = (await this.rpc('refreshToken', { token }));
|
|
542
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
543
|
+
this.setToken(token, at);
|
|
544
|
+
return { expiresAt: this.tokenExpiry };
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
548
|
+
// during the call is reconnecting: either way keep the token for the
|
|
549
|
+
// next join, which checks it.
|
|
550
|
+
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
this.setToken(token, null);
|
|
555
|
+
return { expiresAt: this.tokenExpiry };
|
|
556
|
+
}
|
|
557
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
558
|
+
setToken(token, expiresAt) {
|
|
559
|
+
this.token = token;
|
|
560
|
+
this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
|
|
561
|
+
this.scheduleTokenExpiring();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
565
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
566
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
567
|
+
*/
|
|
568
|
+
scheduleTokenExpiring() {
|
|
569
|
+
if (this.tokenTimer)
|
|
570
|
+
clearTimeout(this.tokenTimer);
|
|
571
|
+
this.tokenTimer = null;
|
|
572
|
+
const expiresAt = this.tokenExpiry;
|
|
573
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
|
|
574
|
+
return;
|
|
575
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
576
|
+
const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
577
|
+
this.tokenTimer = setTimeout(() => {
|
|
578
|
+
this.tokenTimer = null;
|
|
579
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
|
|
580
|
+
return;
|
|
581
|
+
if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
582
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
586
|
+
}, delay);
|
|
587
|
+
}
|
|
588
|
+
// ------------------------------------------------------------ network quality
|
|
589
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
590
|
+
syncNetworkWatch() {
|
|
591
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
592
|
+
}
|
|
593
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
594
|
+
async transportStats() {
|
|
595
|
+
const out = new Map();
|
|
596
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
597
|
+
if (!transport || transport.closed)
|
|
598
|
+
continue;
|
|
599
|
+
const report = await transport.getStats();
|
|
600
|
+
out.set(transport.id, report.values());
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
406
604
|
// -------------------------------------------------------------------- rpc
|
|
407
605
|
rpc(method, params = {}) {
|
|
408
606
|
return new Promise((resolve, reject) => {
|
|
409
607
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
410
|
-
reject(new
|
|
608
|
+
reject(new RpcError('not-connected', 'socket is not open'));
|
|
411
609
|
return;
|
|
412
610
|
}
|
|
413
611
|
const id = this.nextId++;
|
|
414
612
|
const timer = setTimeout(() => {
|
|
415
613
|
this.pending.delete(id);
|
|
416
|
-
reject(new
|
|
614
|
+
reject(new RpcError('timeout', `${method} timed out`));
|
|
417
615
|
}, RPC_TIMEOUT_MS);
|
|
418
616
|
this.pending.set(id, { resolve, reject, timer });
|
|
419
617
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -444,7 +642,7 @@ export class StreamClient {
|
|
|
444
642
|
p.resolve(msg.result);
|
|
445
643
|
else {
|
|
446
644
|
const e = msg.error;
|
|
447
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
645
|
+
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
448
646
|
}
|
|
449
647
|
return;
|
|
450
648
|
}
|
|
@@ -462,17 +660,27 @@ export class StreamClient {
|
|
|
462
660
|
this.preferredLayers.delete(producerId);
|
|
463
661
|
this.receiveQualities.delete(producerId);
|
|
464
662
|
this.remoteVideoLayers.delete(producerId);
|
|
663
|
+
this.skippedVideos.delete(producerId);
|
|
465
664
|
const consumer = this.consumers.get(producerId);
|
|
466
665
|
if (!consumer)
|
|
467
666
|
break; // never consumed it, or already cleaned up
|
|
667
|
+
this.videoSlots.delete(producerId);
|
|
468
668
|
consumer.close();
|
|
469
669
|
this.consumers.delete(producerId);
|
|
470
670
|
this.remoteSources.delete(producerId);
|
|
671
|
+
this.remoteInfo.delete(producerId);
|
|
471
672
|
this.emit('trackEnded', { producerId, identity });
|
|
673
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
674
|
+
// waiting takes it.
|
|
675
|
+
if (consumer.kind === 'video')
|
|
676
|
+
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
472
677
|
break;
|
|
473
678
|
}
|
|
474
679
|
case 'producerPaused': {
|
|
475
680
|
const p = data;
|
|
681
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
682
|
+
if (info)
|
|
683
|
+
info.paused = Boolean(p.paused);
|
|
476
684
|
this.emit('trackMuted', {
|
|
477
685
|
identity: p.identity,
|
|
478
686
|
kind: p.kind,
|
|
@@ -482,27 +690,40 @@ export class StreamClient {
|
|
|
482
690
|
});
|
|
483
691
|
break;
|
|
484
692
|
}
|
|
485
|
-
case 'participantJoined':
|
|
486
|
-
|
|
693
|
+
case 'participantJoined': {
|
|
694
|
+
const p = data;
|
|
695
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
696
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
697
|
+
this.emit('participantJoined', p);
|
|
487
698
|
break;
|
|
699
|
+
}
|
|
488
700
|
case 'participantMuted': {
|
|
489
701
|
const wire = data;
|
|
490
702
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
});
|
|
703
|
+
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
704
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
705
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
706
|
+
}));
|
|
496
707
|
this.emit('participantMuted', e);
|
|
497
708
|
break;
|
|
498
709
|
}
|
|
499
|
-
case '
|
|
500
|
-
|
|
710
|
+
case 'participantUpdated': {
|
|
711
|
+
const e = data;
|
|
712
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
713
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case 'participantLeft': {
|
|
717
|
+
const e = data;
|
|
718
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
719
|
+
this.emit('participantLeft', e);
|
|
501
720
|
break;
|
|
721
|
+
}
|
|
502
722
|
case 'roleChanged': {
|
|
503
723
|
const e = data;
|
|
504
724
|
if (e.identity === this.joinResult?.identity)
|
|
505
725
|
this.joinResult.role = e.role;
|
|
726
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
506
727
|
this.emit('roleChanged', e);
|
|
507
728
|
// Demoted: the server has already closed our producers. Release the
|
|
508
729
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -513,9 +734,13 @@ export class StreamClient {
|
|
|
513
734
|
break;
|
|
514
735
|
}
|
|
515
736
|
case 'handRaised':
|
|
516
|
-
case 'handLowered':
|
|
737
|
+
case 'handLowered': {
|
|
738
|
+
const e = data;
|
|
739
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
740
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
517
741
|
this.emit('handRaised', data);
|
|
518
742
|
break;
|
|
743
|
+
}
|
|
519
744
|
case 'forceMuted': {
|
|
520
745
|
// The server has already paused that kind (an audio mute includes
|
|
521
746
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -528,20 +753,33 @@ export class StreamClient {
|
|
|
528
753
|
this.emit('forceMuted', e);
|
|
529
754
|
break;
|
|
530
755
|
}
|
|
756
|
+
case 'claimChanged': {
|
|
757
|
+
const e = data;
|
|
758
|
+
this.applyClaim(e);
|
|
759
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
760
|
+
break;
|
|
761
|
+
}
|
|
762
|
+
case 'roomMetadataChanged': {
|
|
763
|
+
const e = data;
|
|
764
|
+
if (this.joinResult)
|
|
765
|
+
this.joinResult.metadata = e.metadata;
|
|
766
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
531
769
|
case 'tokenRefreshed': {
|
|
532
770
|
// A room outlives its join token many times over. The server hands us a
|
|
533
771
|
// fresh one periodically so a reconnect at any point still works --
|
|
534
772
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
535
773
|
const e = data;
|
|
536
774
|
if (e.token)
|
|
537
|
-
this.token
|
|
775
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
538
776
|
break;
|
|
539
777
|
}
|
|
540
778
|
case 'mustReconnect': {
|
|
541
779
|
const e = data;
|
|
542
780
|
// A promotion hands us a fresh token carrying the new role.
|
|
543
781
|
if (e.token)
|
|
544
|
-
this.
|
|
782
|
+
this.setToken(e.token, null);
|
|
545
783
|
this.emit('mustReconnect', e);
|
|
546
784
|
if (e.retry) {
|
|
547
785
|
this.teardownMedia();
|
|
@@ -557,16 +795,22 @@ export class StreamClient {
|
|
|
557
795
|
case 'usageLimit':
|
|
558
796
|
this.emit('usageLimit', d);
|
|
559
797
|
break;
|
|
798
|
+
case 'walletLow': {
|
|
799
|
+
const e = data;
|
|
800
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
801
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
560
804
|
case 'roomClosed':
|
|
561
805
|
this.closedByUs = true;
|
|
562
806
|
this.emit('roomClosed', d);
|
|
563
|
-
this.
|
|
807
|
+
this.finish('room-closed');
|
|
564
808
|
this.close();
|
|
565
809
|
break;
|
|
566
810
|
case 'kicked':
|
|
567
811
|
this.closedByUs = true;
|
|
568
812
|
this.emit('kicked', d);
|
|
569
|
-
this.
|
|
813
|
+
this.finish('kicked');
|
|
570
814
|
this.close();
|
|
571
815
|
break;
|
|
572
816
|
case 'message':
|
|
@@ -585,6 +829,25 @@ export class StreamClient {
|
|
|
585
829
|
break;
|
|
586
830
|
}
|
|
587
831
|
}
|
|
832
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
833
|
+
updateRoster(change) {
|
|
834
|
+
if (this.joinResult)
|
|
835
|
+
this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
836
|
+
}
|
|
837
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
838
|
+
applyClaim(e) {
|
|
839
|
+
const join = this.joinResult;
|
|
840
|
+
if (!join)
|
|
841
|
+
return;
|
|
842
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
843
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
844
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity)
|
|
845
|
+
claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
846
|
+
if (e.state === 'blocked')
|
|
847
|
+
blocked = [...blocked, e.key];
|
|
848
|
+
join.claims = claims;
|
|
849
|
+
join.blockedSlots = blocked;
|
|
850
|
+
}
|
|
588
851
|
// ------------------------------------------------------------------ media
|
|
589
852
|
ensureSendTransport() {
|
|
590
853
|
if (this.sendTransport)
|
|
@@ -598,7 +861,7 @@ export class StreamClient {
|
|
|
598
861
|
}
|
|
599
862
|
async createSendTransport() {
|
|
600
863
|
if (!this.device)
|
|
601
|
-
throw new
|
|
864
|
+
throw new RpcError('not-connected', 'not connected');
|
|
602
865
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
603
866
|
const transport = this.device.createSendTransport({
|
|
604
867
|
...params,
|
|
@@ -610,7 +873,10 @@ export class StreamClient {
|
|
|
610
873
|
.catch(fail);
|
|
611
874
|
});
|
|
612
875
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
613
|
-
this.rpc('produce', {
|
|
876
|
+
this.rpc('produce', {
|
|
877
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
878
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
879
|
+
})
|
|
614
880
|
.then((res) => ok({ id: res.producerId }))
|
|
615
881
|
.catch(fail);
|
|
616
882
|
});
|
|
@@ -629,7 +895,7 @@ export class StreamClient {
|
|
|
629
895
|
}
|
|
630
896
|
async createRecvTransport() {
|
|
631
897
|
if (!this.device)
|
|
632
|
-
throw new
|
|
898
|
+
throw new RpcError('not-connected', 'not connected');
|
|
633
899
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
634
900
|
const transport = this.device.createRecvTransport({
|
|
635
901
|
...params,
|
|
@@ -656,12 +922,24 @@ export class StreamClient {
|
|
|
656
922
|
* kept for a reconnect's republish.
|
|
657
923
|
*/
|
|
658
924
|
async publish(stream, constraints, options) {
|
|
925
|
+
// A kind the project switched off is refused here, before anything is
|
|
926
|
+
// produced, with the code the controller would answer with.
|
|
927
|
+
const permissions = this.mediaPermissions;
|
|
928
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
929
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
930
|
+
}
|
|
931
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
932
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
933
|
+
}
|
|
934
|
+
await this.publishStream(stream, constraints, options);
|
|
935
|
+
}
|
|
936
|
+
async publishStream(stream, constraints, options) {
|
|
659
937
|
const generation = this.publishGeneration;
|
|
660
938
|
const transport = await this.ensureSendTransport();
|
|
661
939
|
// unpublish() ran first: this publish is over before it began, and must
|
|
662
940
|
// leave nothing behind that a reconnect would publish again.
|
|
663
941
|
if (generation !== this.publishGeneration)
|
|
664
|
-
throw
|
|
942
|
+
throw unpublishedWhile('stream');
|
|
665
943
|
this.localStream = stream;
|
|
666
944
|
this.wasPublishing = true;
|
|
667
945
|
if (constraints)
|
|
@@ -670,35 +948,140 @@ export class StreamClient {
|
|
|
670
948
|
this.publishOptions = { ...options };
|
|
671
949
|
this.frameRateFallback = false;
|
|
672
950
|
this.frameRateLowSamples = 0;
|
|
951
|
+
this.audioCapReported = false;
|
|
952
|
+
this.sizeCapReported = false;
|
|
673
953
|
}
|
|
954
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
955
|
+
// what is still allowed goes out, and the rest is reported.
|
|
956
|
+
const permissions = this.mediaPermissions;
|
|
674
957
|
const audio = stream.getAudioTracks()[0];
|
|
675
|
-
if (audio) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
stopTracks: false,
|
|
681
|
-
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
682
|
-
appData: { source: 'mic' },
|
|
683
|
-
});
|
|
684
|
-
await this.keepProducer(producer, generation, 'microphone');
|
|
685
|
-
// A microphone handed over switched off, or muted before a reconnect.
|
|
686
|
-
await this.pauseOnServer(producer);
|
|
958
|
+
if (audio && !permissions.audio) {
|
|
959
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
960
|
+
}
|
|
961
|
+
else if (audio) {
|
|
962
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
687
963
|
}
|
|
688
964
|
const video = stream.getVideoTracks()[0];
|
|
689
|
-
if (video) {
|
|
965
|
+
if (video && !permissions.video) {
|
|
966
|
+
this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
967
|
+
}
|
|
968
|
+
else if (video) {
|
|
690
969
|
// The track is read when the camera's turn comes: one handed to
|
|
691
970
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
692
971
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
693
972
|
}
|
|
694
973
|
}
|
|
974
|
+
// ------------------------------------------------------------ audio quality
|
|
975
|
+
/**
|
|
976
|
+
* The microphone's audio quality: what it is being sent at while
|
|
977
|
+
* published, else what the next publish will use, after the project's limit.
|
|
978
|
+
*/
|
|
979
|
+
get audioQuality() {
|
|
980
|
+
if (this.micProducer())
|
|
981
|
+
return this.micQuality;
|
|
982
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
986
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
987
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
988
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
989
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
990
|
+
* only. Resolves to the quality in use.
|
|
991
|
+
*/
|
|
992
|
+
async setAudioQuality(quality) {
|
|
993
|
+
if (quality !== 'std' && quality !== 'hd')
|
|
994
|
+
throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
995
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
996
|
+
throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
997
|
+
}
|
|
998
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
999
|
+
this.audioCapReported = false;
|
|
1000
|
+
await this.micTask(async () => {
|
|
1001
|
+
const current = this.micProducer();
|
|
1002
|
+
const track = current?.track;
|
|
1003
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1004
|
+
return;
|
|
1005
|
+
await this.reproduceMic(current, track);
|
|
1006
|
+
});
|
|
1007
|
+
return this.audioQuality;
|
|
1008
|
+
}
|
|
1009
|
+
micProducer() {
|
|
1010
|
+
for (const p of this.producers.values()) {
|
|
1011
|
+
if (p.kind === 'audio' && p.appData.source === 'mic')
|
|
1012
|
+
return p;
|
|
1013
|
+
}
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
micTask(task) {
|
|
1017
|
+
const run = this.micQueue.then(task, task);
|
|
1018
|
+
this.micQueue = run.catch(() => undefined);
|
|
1019
|
+
return run;
|
|
1020
|
+
}
|
|
1021
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1022
|
+
audioQualityFor() {
|
|
1023
|
+
if (this.publishOptions.audioQuality !== 'hd')
|
|
1024
|
+
return 'std';
|
|
1025
|
+
if (this.mediaPermissions.audioQualityMax === 'hd')
|
|
1026
|
+
return 'hd';
|
|
1027
|
+
if (!this.audioCapReported) {
|
|
1028
|
+
this.audioCapReported = true;
|
|
1029
|
+
this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1030
|
+
}
|
|
1031
|
+
return 'std';
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1035
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1036
|
+
* paused, on both ends.
|
|
1037
|
+
*/
|
|
1038
|
+
async produceMic(transport, track, generation) {
|
|
1039
|
+
if (generation !== this.publishGeneration)
|
|
1040
|
+
throw unpublishedWhile('microphone');
|
|
1041
|
+
const quality = this.audioQualityFor();
|
|
1042
|
+
const settings = AUDIO_SETTINGS[quality];
|
|
1043
|
+
const producer = await transport.produce({
|
|
1044
|
+
track,
|
|
1045
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1046
|
+
// every producer, and a stopped track cannot be republished.
|
|
1047
|
+
stopTracks: false,
|
|
1048
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1049
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1050
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1051
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1052
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1053
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1054
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1055
|
+
});
|
|
1056
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1057
|
+
this.micQuality = quality;
|
|
1058
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1059
|
+
await this.pauseOnServer(producer);
|
|
1060
|
+
return producer;
|
|
1061
|
+
}
|
|
1062
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1063
|
+
async reproduceMic(current, track) {
|
|
1064
|
+
const generation = this.publishGeneration;
|
|
1065
|
+
current.close();
|
|
1066
|
+
this.producers.delete(current.id);
|
|
1067
|
+
// The server allows one producer per source: the old one goes first.
|
|
1068
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1069
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1070
|
+
}
|
|
695
1071
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
696
1072
|
get videoPolicy() {
|
|
697
1073
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
698
1074
|
}
|
|
699
|
-
/**
|
|
1075
|
+
/**
|
|
1076
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1077
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1078
|
+
*/
|
|
700
1079
|
get videoQuality() {
|
|
701
|
-
return
|
|
1080
|
+
return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1081
|
+
}
|
|
1082
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1083
|
+
get videoFrameRate() {
|
|
1084
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
702
1085
|
}
|
|
703
1086
|
/**
|
|
704
1087
|
* Change the video quality while publishing. The camera's producer is
|
|
@@ -708,6 +1091,7 @@ export class StreamClient {
|
|
|
708
1091
|
*/
|
|
709
1092
|
async setVideoQuality(quality) {
|
|
710
1093
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1094
|
+
this.sizeCapReported = false;
|
|
711
1095
|
await this.cameraTask(async () => {
|
|
712
1096
|
const current = this.cameraProducer();
|
|
713
1097
|
const track = current?.track;
|
|
@@ -717,25 +1101,54 @@ export class StreamClient {
|
|
|
717
1101
|
});
|
|
718
1102
|
return this.videoQuality;
|
|
719
1103
|
}
|
|
720
|
-
/**
|
|
1104
|
+
/**
|
|
1105
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1106
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1107
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1108
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1109
|
+
* the frame rate used.
|
|
1110
|
+
*/
|
|
721
1111
|
async setVideoFrameRate(frameRate) {
|
|
722
1112
|
if (frameRate !== 30 && frameRate !== 60)
|
|
723
1113
|
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
724
1114
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
725
1115
|
this.frameRateFallback = false;
|
|
726
1116
|
this.frameRateLowSamples = 0;
|
|
1117
|
+
this.sizeCapReported = false;
|
|
1118
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1119
|
+
this.frameRateFallback = true;
|
|
1120
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1121
|
+
return this.videoFrameRate;
|
|
1122
|
+
}
|
|
727
1123
|
await this.cameraTask(async () => {
|
|
728
1124
|
const current = this.cameraProducer();
|
|
729
1125
|
const track = current?.track;
|
|
730
1126
|
if (!current || !track || track.readyState !== 'live')
|
|
731
1127
|
return;
|
|
732
1128
|
try {
|
|
733
|
-
await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
|
|
1129
|
+
await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
|
|
734
1130
|
}
|
|
735
1131
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
736
1132
|
await this.reproduceCamera(current, track);
|
|
737
1133
|
});
|
|
738
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1134
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1135
|
+
}
|
|
1136
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1137
|
+
allows60() {
|
|
1138
|
+
const permissions = this.mediaPermissions;
|
|
1139
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1143
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1144
|
+
*/
|
|
1145
|
+
cameraQuality(frameRate) {
|
|
1146
|
+
const permissions = this.mediaPermissions;
|
|
1147
|
+
if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
|
|
1148
|
+
this.sizeCapReported = true;
|
|
1149
|
+
this.emit('error', new RpcError('video-quality-not-allowed', `${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
|
|
1150
|
+
}
|
|
1151
|
+
return qualityAt(this.publishOptions.quality, frameRate, permissions);
|
|
739
1152
|
}
|
|
740
1153
|
/**
|
|
741
1154
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
@@ -781,7 +1194,8 @@ export class StreamClient {
|
|
|
781
1194
|
const paused = current.paused;
|
|
782
1195
|
try {
|
|
783
1196
|
const { width = 0, height = 0 } = track.getSettings();
|
|
784
|
-
const
|
|
1197
|
+
const frameRate = this.frameRateFor(track);
|
|
1198
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
785
1199
|
const previousSize = current.track?.getSettings();
|
|
786
1200
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
787
1201
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -974,7 +1388,7 @@ export class StreamClient {
|
|
|
974
1388
|
*/
|
|
975
1389
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
976
1390
|
if (generation !== this.publishGeneration)
|
|
977
|
-
throw
|
|
1391
|
+
throw unpublishedWhile('camera');
|
|
978
1392
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
979
1393
|
// off. Setting the track to the camera's state first makes the producer
|
|
980
1394
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -985,8 +1399,9 @@ export class StreamClient {
|
|
|
985
1399
|
// the device, so the browser sends every layer asked for (see
|
|
986
1400
|
// videoEncodings).
|
|
987
1401
|
const { width = 0, height = 0 } = track.getSettings();
|
|
988
|
-
|
|
1402
|
+
// The frame rate first: the size the project allows depends on it.
|
|
989
1403
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1404
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
990
1405
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
991
1406
|
let codecs;
|
|
992
1407
|
try {
|
|
@@ -1036,7 +1451,7 @@ export class StreamClient {
|
|
|
1036
1451
|
if (generation !== this.publishGeneration) {
|
|
1037
1452
|
producer.close();
|
|
1038
1453
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1039
|
-
throw
|
|
1454
|
+
throw unpublishedWhile(what);
|
|
1040
1455
|
}
|
|
1041
1456
|
this.producers.set(producer.id, producer);
|
|
1042
1457
|
}
|
|
@@ -1075,7 +1490,7 @@ export class StreamClient {
|
|
|
1075
1490
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1076
1491
|
reportCodecFallback(message) {
|
|
1077
1492
|
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1078
|
-
this.emit('error', new
|
|
1493
|
+
this.emit('error', new RpcError('codec-fallback', message));
|
|
1079
1494
|
}
|
|
1080
1495
|
else if (!this.codecFallbackLogged) {
|
|
1081
1496
|
this.codecFallbackLogged = true;
|
|
@@ -1133,7 +1548,7 @@ export class StreamClient {
|
|
|
1133
1548
|
const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
|
|
1134
1549
|
if (!live) {
|
|
1135
1550
|
if (!this.publishConstraints) {
|
|
1136
|
-
this.emit('error', new
|
|
1551
|
+
this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1137
1552
|
return;
|
|
1138
1553
|
}
|
|
1139
1554
|
const ended = stream;
|
|
@@ -1155,13 +1570,15 @@ export class StreamClient {
|
|
|
1155
1570
|
}
|
|
1156
1571
|
if (!stream)
|
|
1157
1572
|
return;
|
|
1158
|
-
await this.
|
|
1573
|
+
await this.publishStream(stream);
|
|
1159
1574
|
}
|
|
1160
1575
|
// ------------------------------------------------------------- screen sharing
|
|
1161
1576
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1162
1577
|
async publishScreen(options = {}) {
|
|
1163
1578
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1164
1579
|
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1580
|
+
if (!this.mediaPermissions.video)
|
|
1581
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1165
1582
|
if (this.screenStream || this.screenPending)
|
|
1166
1583
|
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1167
1584
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
@@ -1182,7 +1599,7 @@ export class StreamClient {
|
|
|
1182
1599
|
if (!video || video.readyState !== 'live')
|
|
1183
1600
|
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1184
1601
|
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1185
|
-
throw
|
|
1602
|
+
throw unpublishedWhile('screen');
|
|
1186
1603
|
this.screenStream = stream;
|
|
1187
1604
|
this.screenMode = mode;
|
|
1188
1605
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1192,7 +1609,7 @@ export class StreamClient {
|
|
|
1192
1609
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1193
1610
|
await this.restoreScreen();
|
|
1194
1611
|
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1195
|
-
throw
|
|
1612
|
+
throw unpublishedWhile('screen');
|
|
1196
1613
|
this.screenStarted = true;
|
|
1197
1614
|
this.wasPublishing = true;
|
|
1198
1615
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1256,7 +1673,7 @@ export class StreamClient {
|
|
|
1256
1673
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1257
1674
|
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1258
1675
|
// A project's explicit safety restriction also applies to screen video.
|
|
1259
|
-
const cap = topLayerSize(width, height, this.
|
|
1676
|
+
const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1260
1677
|
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1261
1678
|
if (cap.scale > screenScale)
|
|
1262
1679
|
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
@@ -1264,7 +1681,7 @@ export class StreamClient {
|
|
|
1264
1681
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1265
1682
|
producer.close();
|
|
1266
1683
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1267
|
-
throw
|
|
1684
|
+
throw unpublishedWhile('screen');
|
|
1268
1685
|
}
|
|
1269
1686
|
this.producers.set(producer.id, producer);
|
|
1270
1687
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1275,7 +1692,12 @@ export class StreamClient {
|
|
|
1275
1692
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1276
1693
|
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1277
1694
|
}));
|
|
1278
|
-
|
|
1695
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1696
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
1697
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
1698
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
1699
|
+
audio = undefined;
|
|
1700
|
+
}
|
|
1279
1701
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1280
1702
|
await keep(await transport.produce({
|
|
1281
1703
|
track: audio, stopTracks: false,
|
|
@@ -1283,19 +1705,34 @@ export class StreamClient {
|
|
|
1283
1705
|
appData: { source: 'screen-audio' },
|
|
1284
1706
|
}));
|
|
1285
1707
|
}
|
|
1708
|
+
/**
|
|
1709
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
1710
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
1711
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
1712
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
1713
|
+
* the camera or device cannot provide it.
|
|
1714
|
+
*/
|
|
1286
1715
|
frameRateFor(track) {
|
|
1287
1716
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1288
1717
|
return 30;
|
|
1718
|
+
if (!this.allows60()) {
|
|
1719
|
+
this.frameRateFallback = true;
|
|
1720
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1721
|
+
return 30;
|
|
1722
|
+
}
|
|
1289
1723
|
const captured = track.getSettings().frameRate;
|
|
1290
|
-
if (
|
|
1724
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1291
1725
|
this.frameRateFallback = true;
|
|
1292
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
1726
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1293
1727
|
return 30;
|
|
1294
1728
|
}
|
|
1295
1729
|
return 60;
|
|
1296
1730
|
}
|
|
1297
|
-
async sampleCamera(baseline) {
|
|
1731
|
+
async sampleCamera(baseline, isCurrent) {
|
|
1732
|
+
const sampledProducer = this.cameraProducer();
|
|
1298
1733
|
const stats = await this.collectVideoStats(baseline);
|
|
1734
|
+
if (!isCurrent() || sampledProducer !== this.cameraProducer())
|
|
1735
|
+
return null;
|
|
1299
1736
|
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
|
|
1300
1737
|
return stats;
|
|
1301
1738
|
if (this.cameraProducer()?.paused)
|
|
@@ -1309,12 +1746,12 @@ export class StreamClient {
|
|
|
1309
1746
|
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1310
1747
|
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1311
1748
|
if (this.frameRateLowSamples >= 3) {
|
|
1312
|
-
this.frameRateFallback = true;
|
|
1313
1749
|
void this.cameraTask(async () => {
|
|
1314
1750
|
const producer = this.cameraProducer();
|
|
1315
|
-
if (!producer?.track || producer.closed)
|
|
1751
|
+
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
|
|
1316
1752
|
return;
|
|
1317
|
-
|
|
1753
|
+
this.frameRateFallback = true;
|
|
1754
|
+
await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1318
1755
|
await this.reproduceCamera(producer, producer.track);
|
|
1319
1756
|
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1320
1757
|
}).catch((error) => this.emit('error', error));
|
|
@@ -1333,6 +1770,8 @@ export class StreamClient {
|
|
|
1333
1770
|
* the user has not interacted with.
|
|
1334
1771
|
*/
|
|
1335
1772
|
async publishMusic(input, options) {
|
|
1773
|
+
if (!this.mediaPermissions.audio)
|
|
1774
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1336
1775
|
const generation = this.publishGeneration;
|
|
1337
1776
|
if (this.music)
|
|
1338
1777
|
await this.stopMusic();
|
|
@@ -1343,7 +1782,7 @@ export class StreamClient {
|
|
|
1343
1782
|
// already closed it, and the music must not be remembered for a
|
|
1344
1783
|
// reconnect to publish again.
|
|
1345
1784
|
if (generation !== this.publishGeneration)
|
|
1346
|
-
throw
|
|
1785
|
+
throw unpublishedWhile('music');
|
|
1347
1786
|
}
|
|
1348
1787
|
catch (err) {
|
|
1349
1788
|
music.close();
|
|
@@ -1356,7 +1795,7 @@ export class StreamClient {
|
|
|
1356
1795
|
async produceMusic(music, generation) {
|
|
1357
1796
|
const transport = await this.ensureSendTransport();
|
|
1358
1797
|
if (generation !== this.publishGeneration)
|
|
1359
|
-
throw
|
|
1798
|
+
throw unpublishedWhile('music');
|
|
1360
1799
|
const producer = await transport.produce({
|
|
1361
1800
|
track: music.track,
|
|
1362
1801
|
stopTracks: false,
|
|
@@ -1416,13 +1855,49 @@ export class StreamClient {
|
|
|
1416
1855
|
}
|
|
1417
1856
|
async consume(remote) {
|
|
1418
1857
|
if (!this.device)
|
|
1419
|
-
throw new
|
|
1858
|
+
throw new RpcError('not-connected', 'not connected');
|
|
1420
1859
|
if (this.consumers.has(remote.producerId))
|
|
1421
1860
|
return;
|
|
1422
1861
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1423
1862
|
// consumer and shows you a delayed copy of yourself.
|
|
1424
1863
|
if (remote.identity === this.joinResult?.identity)
|
|
1425
1864
|
return;
|
|
1865
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
1866
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
1867
|
+
// asking for a refusal, and say so once per video.
|
|
1868
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
1869
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1870
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
1871
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
1872
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
1873
|
+
this.emit('error', new RpcError('video-subscription-limit', `already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
|
|
1874
|
+
}
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
this.videoSlots.add(remote.producerId);
|
|
1878
|
+
}
|
|
1879
|
+
try {
|
|
1880
|
+
await this.consumeNow(remote);
|
|
1881
|
+
}
|
|
1882
|
+
catch (err) {
|
|
1883
|
+
if (!this.consumers.has(remote.producerId))
|
|
1884
|
+
this.videoSlots.delete(remote.producerId);
|
|
1885
|
+
throw err;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1889
|
+
async consumeSkipped() {
|
|
1890
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
1891
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1892
|
+
if (limit !== null && this.videoSlots.size >= limit)
|
|
1893
|
+
return;
|
|
1894
|
+
this.skippedVideos.delete(producerId);
|
|
1895
|
+
await this.consume(remote);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
async consumeNow(remote) {
|
|
1899
|
+
if (!this.device)
|
|
1900
|
+
throw new RpcError('not-connected', 'not connected');
|
|
1426
1901
|
const transport = await this.ensureRecvTransport();
|
|
1427
1902
|
const params = (await this.rpc('consume', {
|
|
1428
1903
|
transportId: transport.id,
|
|
@@ -1455,6 +1930,7 @@ export class StreamClient {
|
|
|
1455
1930
|
const kind = consumer.kind;
|
|
1456
1931
|
const source = remote.source ?? defaultSource(kind);
|
|
1457
1932
|
this.remoteSources.set(remote.producerId, source);
|
|
1933
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1458
1934
|
this.emit('track', {
|
|
1459
1935
|
identity: remote.identity,
|
|
1460
1936
|
kind,
|
|
@@ -1599,6 +2075,57 @@ export class StreamClient {
|
|
|
1599
2075
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
1600
2076
|
endRoom() { return this.rpc('endRoom'); }
|
|
1601
2077
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2078
|
+
// ------------------------------------------------------- slots and metadata
|
|
2079
|
+
//
|
|
2080
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2081
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2082
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2083
|
+
// seed a board on join and follow claimChanged.
|
|
2084
|
+
/**
|
|
2085
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2086
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2087
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2088
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2089
|
+
*/
|
|
2090
|
+
async claim(key, identity) {
|
|
2091
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2092
|
+
}
|
|
2093
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2094
|
+
async release(key) {
|
|
2095
|
+
await this.rpc('release', { key });
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2099
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2100
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2101
|
+
*/
|
|
2102
|
+
async blockSlot(key) {
|
|
2103
|
+
await this.rpc('blockSlot', { key });
|
|
2104
|
+
}
|
|
2105
|
+
/** Unlock a slot. Moderators only. */
|
|
2106
|
+
async unblockSlot(key) {
|
|
2107
|
+
await this.rpc('unblockSlot', { key });
|
|
2108
|
+
}
|
|
2109
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2110
|
+
async setMetadata(metadata) {
|
|
2111
|
+
await this.rpc('setMetadata', { metadata });
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2115
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2116
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2117
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2118
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2119
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2120
|
+
*/
|
|
2121
|
+
async setRoomMetadata(metadata, options = {}) {
|
|
2122
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2123
|
+
metadata,
|
|
2124
|
+
...(options.replace ? { replace: true } : {}),
|
|
2125
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2126
|
+
}));
|
|
2127
|
+
return res?.metadata ?? null;
|
|
2128
|
+
}
|
|
1602
2129
|
// ------------------------------------------------------------------- chat
|
|
1603
2130
|
//
|
|
1604
2131
|
// The room's chat rides this same socket. Someone on a chat-only socket
|