@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/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". */
|
|
@@ -58,10 +55,32 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
58
55
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
59
56
|
*/
|
|
60
57
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
58
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
59
|
+
const closedWhileConnecting = () => new RpcError('not-connected', 'the session was closed while connecting');
|
|
61
60
|
/** 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
|
|
61
|
+
const unpublishedWhile = (what) => new RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
62
|
+
/**
|
|
63
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
64
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
65
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
66
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
67
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
68
|
+
* audio go out as they always did.
|
|
69
|
+
*/
|
|
70
|
+
function declareAudioQuality(rtpParameters, appData) {
|
|
71
|
+
const quality = appData.audioQuality;
|
|
72
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
|
|
73
|
+
return rtpParameters;
|
|
74
|
+
const maxaveragebitrate = AUDIO_SETTINGS[quality].maxBitrate;
|
|
75
|
+
return {
|
|
76
|
+
...rtpParameters,
|
|
77
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
78
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
63
81
|
export class StreamClient {
|
|
64
82
|
wsUrl;
|
|
83
|
+
options;
|
|
65
84
|
ws = null;
|
|
66
85
|
device = null;
|
|
67
86
|
sendTransport = null;
|
|
@@ -113,7 +132,15 @@ export class StreamClient {
|
|
|
113
132
|
* the full picture.
|
|
114
133
|
*/
|
|
115
134
|
preferredLayers = new Map();
|
|
135
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
136
|
+
endRoomWaiters = new Set();
|
|
116
137
|
closedByUs = false;
|
|
138
|
+
/**
|
|
139
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
140
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
141
|
+
* after its next await and stops, so a closed session never comes back.
|
|
142
|
+
*/
|
|
143
|
+
lifecycle = 0;
|
|
117
144
|
reconnecting = false;
|
|
118
145
|
reconnectAttempt = 0;
|
|
119
146
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -132,9 +159,30 @@ export class StreamClient {
|
|
|
132
159
|
sendStatsBaseline = new Map();
|
|
133
160
|
recvStatsBaseline = new Map();
|
|
134
161
|
limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
135
|
-
|
|
162
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
163
|
+
state = 'idle';
|
|
164
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
165
|
+
tokenExpiry;
|
|
166
|
+
tokenTimer = null;
|
|
167
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
168
|
+
remoteInfo = new Map();
|
|
169
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
170
|
+
skippedVideos = new Map();
|
|
171
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
172
|
+
videoSlots = new Set();
|
|
173
|
+
/** The quality the microphone producer was made with. */
|
|
174
|
+
micQuality = 'std';
|
|
175
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
176
|
+
micQueue = Promise.resolve();
|
|
177
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
178
|
+
audioCapReported = false;
|
|
179
|
+
sizeCapReported = false;
|
|
180
|
+
networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
181
|
+
constructor(wsUrl, token, options = {}) {
|
|
136
182
|
this.wsUrl = wsUrl;
|
|
183
|
+
this.options = options;
|
|
137
184
|
this.token = token;
|
|
185
|
+
this.tokenExpiry = tokenExpiresAt(token);
|
|
138
186
|
}
|
|
139
187
|
on(event, fn) {
|
|
140
188
|
let set = this.listeners.get(event);
|
|
@@ -143,15 +191,46 @@ export class StreamClient {
|
|
|
143
191
|
this.listeners.set(event, set);
|
|
144
192
|
}
|
|
145
193
|
set.add(fn);
|
|
146
|
-
// videoLimited
|
|
194
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
195
|
+
// they run only while heard.
|
|
147
196
|
if (event === 'videoLimited')
|
|
148
197
|
this.syncLimitWatch();
|
|
198
|
+
if (event === 'networkQuality')
|
|
199
|
+
this.syncNetworkWatch();
|
|
149
200
|
return () => {
|
|
150
201
|
set.delete(fn);
|
|
151
202
|
if (event === 'videoLimited')
|
|
152
203
|
this.syncLimitWatch();
|
|
204
|
+
if (event === 'networkQuality')
|
|
205
|
+
this.syncNetworkWatch();
|
|
153
206
|
};
|
|
154
207
|
}
|
|
208
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
209
|
+
get connectionState() {
|
|
210
|
+
return this.state;
|
|
211
|
+
}
|
|
212
|
+
setState(next) {
|
|
213
|
+
if (next === this.state)
|
|
214
|
+
return;
|
|
215
|
+
const previous = this.state;
|
|
216
|
+
this.state = next;
|
|
217
|
+
if (next === 'closed')
|
|
218
|
+
this.stopTimers();
|
|
219
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
220
|
+
}
|
|
221
|
+
/** Over for good: the state, then the event that says why. */
|
|
222
|
+
finish(reason) {
|
|
223
|
+
this.closedByUs = true;
|
|
224
|
+
this.lifecycle += 1;
|
|
225
|
+
this.setState('closed');
|
|
226
|
+
this.emit('closed', reason);
|
|
227
|
+
}
|
|
228
|
+
stopTimers() {
|
|
229
|
+
if (this.tokenTimer)
|
|
230
|
+
clearTimeout(this.tokenTimer);
|
|
231
|
+
this.tokenTimer = null;
|
|
232
|
+
this.networkWatch.set(false);
|
|
233
|
+
}
|
|
155
234
|
listenerCount(event) {
|
|
156
235
|
return this.listeners.get(event)?.size ?? 0;
|
|
157
236
|
}
|
|
@@ -167,20 +246,31 @@ export class StreamClient {
|
|
|
167
246
|
}
|
|
168
247
|
// ------------------------------------------------------------- connection
|
|
169
248
|
async connect() {
|
|
249
|
+
const lifecycle = this.lifecycle;
|
|
250
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
170
251
|
let result;
|
|
252
|
+
if (this.state === 'idle' || this.state === 'closed')
|
|
253
|
+
this.setState('connecting');
|
|
171
254
|
try {
|
|
172
255
|
await this.openSocket();
|
|
173
256
|
result = (await this.rpc('join', { token: this.token }));
|
|
174
257
|
this.joinResult = result;
|
|
175
258
|
this.device = this.createDevice();
|
|
176
259
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
260
|
+
// close() ran while the Device loaded: going on would set the state
|
|
261
|
+
// back to connected, and emit joined, after closed.
|
|
262
|
+
if (closed())
|
|
263
|
+
throw closedWhileConnecting();
|
|
177
264
|
}
|
|
178
265
|
catch (err) {
|
|
179
266
|
// A session that never got going: the app has the error. Left open,
|
|
180
267
|
// the socket would be closed by the server's join deadline instead, and
|
|
181
268
|
// that close would start reconnecting a session that never began.
|
|
182
|
-
if (!this.joinedOnce)
|
|
269
|
+
if (!this.joinedOnce) {
|
|
183
270
|
this.detachSocket();
|
|
271
|
+
if (this.state === 'connecting')
|
|
272
|
+
this.setState('idle');
|
|
273
|
+
}
|
|
184
274
|
throw err;
|
|
185
275
|
}
|
|
186
276
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -189,10 +279,15 @@ export class StreamClient {
|
|
|
189
279
|
// retried about twice a second, forever.
|
|
190
280
|
this.reconnectAttempt = 0;
|
|
191
281
|
this.joinedOnce = true;
|
|
282
|
+
this.skippedVideos.clear();
|
|
283
|
+
this.setState('connected');
|
|
284
|
+
this.scheduleTokenExpiring();
|
|
192
285
|
// A role or mute can change while the socket is away. The fresh roster
|
|
193
286
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
194
287
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
195
288
|
await this.unpublish();
|
|
289
|
+
if (closed())
|
|
290
|
+
throw closedWhileConnecting();
|
|
196
291
|
}
|
|
197
292
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
198
293
|
if (own?.muted)
|
|
@@ -214,20 +309,33 @@ export class StreamClient {
|
|
|
214
309
|
if (!present.has(producerId))
|
|
215
310
|
this.remoteVideoLayers.delete(producerId);
|
|
216
311
|
// Subscribe to everything already publishing before we announce readiness.
|
|
312
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
217
313
|
for (const p of result.producers) {
|
|
218
|
-
|
|
314
|
+
if (closed())
|
|
315
|
+
throw closedWhileConnecting();
|
|
316
|
+
await this.consume(p).catch((err) => { if (!closed())
|
|
317
|
+
this.emit('error', err); });
|
|
219
318
|
}
|
|
220
319
|
// Restore our own broadcast. This is what makes an origin failover
|
|
221
320
|
// survivable for the host: it reconnects onto the new origin and the
|
|
222
321
|
// stream comes back on its own.
|
|
223
322
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
224
|
-
await this.republish().catch((err) =>
|
|
323
|
+
await this.republish().catch((err) => { if (!closed())
|
|
324
|
+
this.emit('error', err); });
|
|
225
325
|
}
|
|
326
|
+
if (closed())
|
|
327
|
+
throw closedWhileConnecting();
|
|
328
|
+
this.syncNetworkWatch();
|
|
226
329
|
return result;
|
|
227
330
|
}
|
|
228
|
-
/**
|
|
331
|
+
/**
|
|
332
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
333
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
334
|
+
* WebRTC, can stand one in.
|
|
335
|
+
*/
|
|
229
336
|
createDevice() {
|
|
230
|
-
|
|
337
|
+
const { handlerName } = this.options;
|
|
338
|
+
return handlerName ? new Device({ handlerName }) : new Device();
|
|
231
339
|
}
|
|
232
340
|
openSocket() {
|
|
233
341
|
// Abandon any previous socket first. Its close handler checks identity
|
|
@@ -235,15 +343,13 @@ export class StreamClient {
|
|
|
235
343
|
// reconnect -- which is what let multiple reconnect chains pile up.
|
|
236
344
|
this.detachSocket();
|
|
237
345
|
return new Promise((resolve, reject) => {
|
|
238
|
-
const
|
|
239
|
-
url.searchParams.set('token', this.token);
|
|
240
|
-
const ws = new WebSocket(url.toString());
|
|
346
|
+
const ws = new WebSocket(socketUrl(this.wsUrl, this.token));
|
|
241
347
|
this.ws = ws;
|
|
242
348
|
const onOpen = () => {
|
|
243
349
|
this.emit('connected');
|
|
244
350
|
resolve();
|
|
245
351
|
};
|
|
246
|
-
const onError = () => reject(new
|
|
352
|
+
const onError = () => reject(new RpcError('connect-failed', 'websocket failed to open'));
|
|
247
353
|
ws.addEventListener('open', onOpen, { once: true });
|
|
248
354
|
ws.addEventListener('error', onError, { once: true });
|
|
249
355
|
ws.addEventListener('message', (ev) => {
|
|
@@ -256,49 +362,59 @@ export class StreamClient {
|
|
|
256
362
|
if (this.ws !== ws)
|
|
257
363
|
return;
|
|
258
364
|
this.ws = null;
|
|
259
|
-
this.failAllPending(new
|
|
365
|
+
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
260
366
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
261
367
|
if (this.closedByUs)
|
|
262
368
|
return;
|
|
263
369
|
if (ev.code === CLOSE_REPLACED) {
|
|
264
370
|
// Another session for this identity is live. Reconnecting would
|
|
265
371
|
// displace it, and it would displace us right back, forever.
|
|
266
|
-
this.emit('error', new
|
|
267
|
-
this.closedByUs = true;
|
|
372
|
+
this.emit('error', new RpcError('replaced', 'this identity connected from somewhere else'));
|
|
268
373
|
// The producers outlive this close until the app calls close(); the
|
|
269
374
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
270
375
|
this.limitWatch.reset();
|
|
271
|
-
this.
|
|
376
|
+
this.finish('replaced');
|
|
272
377
|
return;
|
|
273
378
|
}
|
|
274
379
|
if (ev.code === CLOSE_ENDED) {
|
|
275
380
|
// Normally roomClosed has already ended the session; this covers a
|
|
276
381
|
// close that arrived without it.
|
|
277
|
-
this.closedByUs = true;
|
|
278
382
|
this.limitWatch.reset();
|
|
279
|
-
this.
|
|
383
|
+
this.finish('room-closed');
|
|
280
384
|
return;
|
|
281
385
|
}
|
|
282
386
|
// Before a first successful join the app is still waiting on
|
|
283
387
|
// connect(), which reports the failure; there is nothing to restore.
|
|
284
388
|
if (!this.joinedOnce)
|
|
285
389
|
return;
|
|
390
|
+
this.setState('reconnecting');
|
|
391
|
+
this.networkWatch.set(false);
|
|
286
392
|
void this.scheduleReconnect();
|
|
287
393
|
});
|
|
288
394
|
});
|
|
289
395
|
}
|
|
290
|
-
detachSocket() {
|
|
396
|
+
detachSocket(reason = 'superseded') {
|
|
291
397
|
const ws = this.ws;
|
|
292
398
|
this.ws = null;
|
|
293
399
|
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
294
400
|
try {
|
|
295
|
-
ws.close(1000,
|
|
401
|
+
ws.close(1000, reason);
|
|
296
402
|
}
|
|
297
403
|
catch {
|
|
298
404
|
/* already closing */
|
|
299
405
|
}
|
|
300
406
|
}
|
|
301
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
410
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
411
|
+
* and the session would otherwise report closed with it still attached and
|
|
412
|
+
* still sending.
|
|
413
|
+
*/
|
|
414
|
+
releaseSocket() {
|
|
415
|
+
this.detachSocket('gave up');
|
|
416
|
+
this.failAllPending(new RpcError('not-connected', 'the session has ended'));
|
|
417
|
+
}
|
|
302
418
|
/**
|
|
303
419
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
304
420
|
* promotions move you to a different node. Always re-ask the orchestrator
|
|
@@ -315,9 +431,9 @@ export class StreamClient {
|
|
|
315
431
|
while (!this.closedByUs) {
|
|
316
432
|
this.reconnectAttempt += 1;
|
|
317
433
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
318
|
-
this.emit('error', new
|
|
319
|
-
this.
|
|
320
|
-
this.
|
|
434
|
+
this.emit('error', new RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
435
|
+
this.releaseSocket();
|
|
436
|
+
this.finish('gave-up');
|
|
321
437
|
return;
|
|
322
438
|
}
|
|
323
439
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -335,13 +451,17 @@ export class StreamClient {
|
|
|
335
451
|
return;
|
|
336
452
|
}
|
|
337
453
|
catch (err) {
|
|
454
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
455
|
+
// server -- and that has been reported: nothing follows closed.
|
|
456
|
+
if (this.closedByUs)
|
|
457
|
+
return;
|
|
338
458
|
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.
|
|
459
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
460
|
+
// codes). Hammering a room that has ended produces one error per
|
|
461
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
462
|
+
if (err instanceof RpcError && isTerminalError(err.code)) {
|
|
463
|
+
this.releaseSocket();
|
|
464
|
+
this.finish('gave-up');
|
|
345
465
|
return;
|
|
346
466
|
}
|
|
347
467
|
}
|
|
@@ -357,6 +477,7 @@ export class StreamClient {
|
|
|
357
477
|
// others have already reported themselves and set closedByUs.
|
|
358
478
|
const wasOpen = !this.closedByUs;
|
|
359
479
|
this.closedByUs = true;
|
|
480
|
+
this.lifecycle += 1;
|
|
360
481
|
this.publishGeneration += 1;
|
|
361
482
|
this.wasPublishing = false;
|
|
362
483
|
this.teardownMedia();
|
|
@@ -368,6 +489,7 @@ export class StreamClient {
|
|
|
368
489
|
this.music = null;
|
|
369
490
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
370
491
|
this.limitWatch.reset();
|
|
492
|
+
this.skippedVideos.clear();
|
|
371
493
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
372
494
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
373
495
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -375,6 +497,11 @@ export class StreamClient {
|
|
|
375
497
|
const ws = this.ws;
|
|
376
498
|
this.ws = null;
|
|
377
499
|
ws?.close(1000, 'client closed');
|
|
500
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
501
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
502
|
+
// wait out its 15 s timeout.
|
|
503
|
+
this.failAllPending(new RpcError('not-connected', 'the session was closed'));
|
|
504
|
+
this.setState('closed');
|
|
378
505
|
if (wasOpen)
|
|
379
506
|
this.emit('closed', 'left');
|
|
380
507
|
}
|
|
@@ -382,6 +509,9 @@ export class StreamClient {
|
|
|
382
509
|
for (const c of this.consumers.values())
|
|
383
510
|
c.close();
|
|
384
511
|
this.consumers.clear();
|
|
512
|
+
this.remoteInfo.clear();
|
|
513
|
+
this.skippedVideos.clear();
|
|
514
|
+
this.videoSlots.clear();
|
|
385
515
|
for (const p of this.producers.values())
|
|
386
516
|
p.close();
|
|
387
517
|
this.producers.clear();
|
|
@@ -398,22 +528,137 @@ export class StreamClient {
|
|
|
398
528
|
this.sendTransportPending = null;
|
|
399
529
|
this.recvTransportPending = null;
|
|
400
530
|
this.device = null;
|
|
531
|
+
this.syncNetworkWatch();
|
|
401
532
|
}
|
|
402
|
-
/**
|
|
533
|
+
/**
|
|
534
|
+
* Everything currently known about the room, for rendering: the join
|
|
535
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
536
|
+
* metadata, slots and room metadata follow the events.
|
|
537
|
+
*/
|
|
403
538
|
get info() {
|
|
404
539
|
return this.joinResult;
|
|
405
540
|
}
|
|
541
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
542
|
+
get participants() {
|
|
543
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
544
|
+
}
|
|
545
|
+
/** Every track this client is receiving now. */
|
|
546
|
+
get remoteTracks() {
|
|
547
|
+
const out = [];
|
|
548
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
549
|
+
const info = this.remoteInfo.get(producerId);
|
|
550
|
+
if (!info || consumer.closed)
|
|
551
|
+
continue;
|
|
552
|
+
out.push({
|
|
553
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
554
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* What this project lets this client publish: from the join result's
|
|
561
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
562
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
563
|
+
* or caps anything else with the server's own codes.
|
|
564
|
+
*/
|
|
565
|
+
get mediaPermissions() {
|
|
566
|
+
return resolvePermissions(this.joinResult);
|
|
567
|
+
}
|
|
568
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
569
|
+
get billingMode() {
|
|
570
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
571
|
+
}
|
|
572
|
+
// ------------------------------------------------------------ token refresh
|
|
573
|
+
/**
|
|
574
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
575
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
576
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
577
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
578
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
579
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
580
|
+
*/
|
|
581
|
+
async updateToken(token) {
|
|
582
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
583
|
+
throw new TypeError('updateToken needs a token');
|
|
584
|
+
if (this.state === 'closed')
|
|
585
|
+
throw new RpcError('not-connected', 'this session is closed; create a new client');
|
|
586
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
587
|
+
try {
|
|
588
|
+
const res = (await this.rpc('refreshToken', { token }));
|
|
589
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
590
|
+
this.setToken(token, at);
|
|
591
|
+
return { expiresAt: this.tokenExpiry };
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
595
|
+
// during the call is reconnecting: either way keep the token for the
|
|
596
|
+
// next join, which checks it.
|
|
597
|
+
if (!(err instanceof RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
this.setToken(token, null);
|
|
602
|
+
return { expiresAt: this.tokenExpiry };
|
|
603
|
+
}
|
|
604
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
605
|
+
setToken(token, expiresAt) {
|
|
606
|
+
this.token = token;
|
|
607
|
+
this.tokenExpiry = expiresAt ?? tokenExpiresAt(token);
|
|
608
|
+
this.scheduleTokenExpiring();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
612
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
613
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
614
|
+
*/
|
|
615
|
+
scheduleTokenExpiring() {
|
|
616
|
+
if (this.tokenTimer)
|
|
617
|
+
clearTimeout(this.tokenTimer);
|
|
618
|
+
this.tokenTimer = null;
|
|
619
|
+
const expiresAt = this.tokenExpiry;
|
|
620
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
|
|
621
|
+
return;
|
|
622
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
623
|
+
const delay = Math.min(Math.max(0, expiresAt - TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
624
|
+
this.tokenTimer = setTimeout(() => {
|
|
625
|
+
this.tokenTimer = null;
|
|
626
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
|
|
627
|
+
return;
|
|
628
|
+
if (expiresAt - TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
629
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
633
|
+
}, delay);
|
|
634
|
+
}
|
|
635
|
+
// ------------------------------------------------------------ network quality
|
|
636
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
637
|
+
syncNetworkWatch() {
|
|
638
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
639
|
+
}
|
|
640
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
641
|
+
async transportStats() {
|
|
642
|
+
const out = new Map();
|
|
643
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
644
|
+
if (!transport || transport.closed)
|
|
645
|
+
continue;
|
|
646
|
+
const report = await transport.getStats();
|
|
647
|
+
out.set(transport.id, report.values());
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
406
651
|
// -------------------------------------------------------------------- rpc
|
|
407
652
|
rpc(method, params = {}) {
|
|
408
653
|
return new Promise((resolve, reject) => {
|
|
409
654
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
410
|
-
reject(new
|
|
655
|
+
reject(new RpcError('not-connected', 'socket is not open'));
|
|
411
656
|
return;
|
|
412
657
|
}
|
|
413
658
|
const id = this.nextId++;
|
|
414
659
|
const timer = setTimeout(() => {
|
|
415
660
|
this.pending.delete(id);
|
|
416
|
-
reject(new
|
|
661
|
+
reject(new RpcError('timeout', `${method} timed out`));
|
|
417
662
|
}, RPC_TIMEOUT_MS);
|
|
418
663
|
this.pending.set(id, { resolve, reject, timer });
|
|
419
664
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -434,6 +679,10 @@ export class StreamClient {
|
|
|
434
679
|
catch {
|
|
435
680
|
return;
|
|
436
681
|
}
|
|
682
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
683
|
+
// ignored like any other frame that is not a message.
|
|
684
|
+
if (typeof msg !== 'object' || msg === null)
|
|
685
|
+
return;
|
|
437
686
|
if (typeof msg.id === 'number') {
|
|
438
687
|
const p = this.pending.get(msg.id);
|
|
439
688
|
if (!p)
|
|
@@ -444,7 +693,7 @@ export class StreamClient {
|
|
|
444
693
|
p.resolve(msg.result);
|
|
445
694
|
else {
|
|
446
695
|
const e = msg.error;
|
|
447
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
696
|
+
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
448
697
|
}
|
|
449
698
|
return;
|
|
450
699
|
}
|
|
@@ -462,17 +711,27 @@ export class StreamClient {
|
|
|
462
711
|
this.preferredLayers.delete(producerId);
|
|
463
712
|
this.receiveQualities.delete(producerId);
|
|
464
713
|
this.remoteVideoLayers.delete(producerId);
|
|
714
|
+
this.skippedVideos.delete(producerId);
|
|
465
715
|
const consumer = this.consumers.get(producerId);
|
|
466
716
|
if (!consumer)
|
|
467
717
|
break; // never consumed it, or already cleaned up
|
|
718
|
+
this.videoSlots.delete(producerId);
|
|
468
719
|
consumer.close();
|
|
469
720
|
this.consumers.delete(producerId);
|
|
470
721
|
this.remoteSources.delete(producerId);
|
|
722
|
+
this.remoteInfo.delete(producerId);
|
|
471
723
|
this.emit('trackEnded', { producerId, identity });
|
|
724
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
725
|
+
// waiting takes it.
|
|
726
|
+
if (consumer.kind === 'video')
|
|
727
|
+
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
472
728
|
break;
|
|
473
729
|
}
|
|
474
730
|
case 'producerPaused': {
|
|
475
731
|
const p = data;
|
|
732
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
733
|
+
if (info)
|
|
734
|
+
info.paused = Boolean(p.paused);
|
|
476
735
|
this.emit('trackMuted', {
|
|
477
736
|
identity: p.identity,
|
|
478
737
|
kind: p.kind,
|
|
@@ -482,27 +741,40 @@ export class StreamClient {
|
|
|
482
741
|
});
|
|
483
742
|
break;
|
|
484
743
|
}
|
|
485
|
-
case 'participantJoined':
|
|
486
|
-
|
|
744
|
+
case 'participantJoined': {
|
|
745
|
+
const p = data;
|
|
746
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
747
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
748
|
+
this.emit('participantJoined', p);
|
|
487
749
|
break;
|
|
750
|
+
}
|
|
488
751
|
case 'participantMuted': {
|
|
489
752
|
const wire = data;
|
|
490
753
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
});
|
|
754
|
+
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
755
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
756
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
757
|
+
}));
|
|
496
758
|
this.emit('participantMuted', e);
|
|
497
759
|
break;
|
|
498
760
|
}
|
|
499
|
-
case '
|
|
500
|
-
|
|
761
|
+
case 'participantUpdated': {
|
|
762
|
+
const e = data;
|
|
763
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
764
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
case 'participantLeft': {
|
|
768
|
+
const e = data;
|
|
769
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
770
|
+
this.emit('participantLeft', e);
|
|
501
771
|
break;
|
|
772
|
+
}
|
|
502
773
|
case 'roleChanged': {
|
|
503
774
|
const e = data;
|
|
504
775
|
if (e.identity === this.joinResult?.identity)
|
|
505
776
|
this.joinResult.role = e.role;
|
|
777
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
506
778
|
this.emit('roleChanged', e);
|
|
507
779
|
// Demoted: the server has already closed our producers. Release the
|
|
508
780
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -513,9 +785,13 @@ export class StreamClient {
|
|
|
513
785
|
break;
|
|
514
786
|
}
|
|
515
787
|
case 'handRaised':
|
|
516
|
-
case 'handLowered':
|
|
788
|
+
case 'handLowered': {
|
|
789
|
+
const e = data;
|
|
790
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
791
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
517
792
|
this.emit('handRaised', data);
|
|
518
793
|
break;
|
|
794
|
+
}
|
|
519
795
|
case 'forceMuted': {
|
|
520
796
|
// The server has already paused that kind (an audio mute includes
|
|
521
797
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -528,20 +804,33 @@ export class StreamClient {
|
|
|
528
804
|
this.emit('forceMuted', e);
|
|
529
805
|
break;
|
|
530
806
|
}
|
|
807
|
+
case 'claimChanged': {
|
|
808
|
+
const e = data;
|
|
809
|
+
this.applyClaim(e);
|
|
810
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
case 'roomMetadataChanged': {
|
|
814
|
+
const e = data;
|
|
815
|
+
if (this.joinResult)
|
|
816
|
+
this.joinResult.metadata = e.metadata;
|
|
817
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
531
820
|
case 'tokenRefreshed': {
|
|
532
821
|
// A room outlives its join token many times over. The server hands us a
|
|
533
822
|
// fresh one periodically so a reconnect at any point still works --
|
|
534
823
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
535
824
|
const e = data;
|
|
536
825
|
if (e.token)
|
|
537
|
-
this.token
|
|
826
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
538
827
|
break;
|
|
539
828
|
}
|
|
540
829
|
case 'mustReconnect': {
|
|
541
830
|
const e = data;
|
|
542
831
|
// A promotion hands us a fresh token carrying the new role.
|
|
543
832
|
if (e.token)
|
|
544
|
-
this.
|
|
833
|
+
this.setToken(e.token, null);
|
|
545
834
|
this.emit('mustReconnect', e);
|
|
546
835
|
if (e.retry) {
|
|
547
836
|
this.teardownMedia();
|
|
@@ -557,16 +846,27 @@ export class StreamClient {
|
|
|
557
846
|
case 'usageLimit':
|
|
558
847
|
this.emit('usageLimit', d);
|
|
559
848
|
break;
|
|
849
|
+
case 'walletLow': {
|
|
850
|
+
const e = data;
|
|
851
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
852
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
560
855
|
case 'roomClosed':
|
|
561
856
|
this.closedByUs = true;
|
|
562
857
|
this.emit('roomClosed', d);
|
|
563
|
-
this.
|
|
858
|
+
this.finish('room-closed');
|
|
859
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
860
|
+
// answers succeeded.
|
|
861
|
+
for (const ended of this.endRoomWaiters)
|
|
862
|
+
ended();
|
|
863
|
+
this.endRoomWaiters.clear();
|
|
564
864
|
this.close();
|
|
565
865
|
break;
|
|
566
866
|
case 'kicked':
|
|
567
867
|
this.closedByUs = true;
|
|
568
868
|
this.emit('kicked', d);
|
|
569
|
-
this.
|
|
869
|
+
this.finish('kicked');
|
|
570
870
|
this.close();
|
|
571
871
|
break;
|
|
572
872
|
case 'message':
|
|
@@ -585,6 +885,25 @@ export class StreamClient {
|
|
|
585
885
|
break;
|
|
586
886
|
}
|
|
587
887
|
}
|
|
888
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
889
|
+
updateRoster(change) {
|
|
890
|
+
if (this.joinResult)
|
|
891
|
+
this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
892
|
+
}
|
|
893
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
894
|
+
applyClaim(e) {
|
|
895
|
+
const join = this.joinResult;
|
|
896
|
+
if (!join)
|
|
897
|
+
return;
|
|
898
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
899
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
900
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity)
|
|
901
|
+
claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
902
|
+
if (e.state === 'blocked')
|
|
903
|
+
blocked = [...blocked, e.key];
|
|
904
|
+
join.claims = claims;
|
|
905
|
+
join.blockedSlots = blocked;
|
|
906
|
+
}
|
|
588
907
|
// ------------------------------------------------------------------ media
|
|
589
908
|
ensureSendTransport() {
|
|
590
909
|
if (this.sendTransport)
|
|
@@ -598,7 +917,7 @@ export class StreamClient {
|
|
|
598
917
|
}
|
|
599
918
|
async createSendTransport() {
|
|
600
919
|
if (!this.device)
|
|
601
|
-
throw new
|
|
920
|
+
throw new RpcError('not-connected', 'not connected');
|
|
602
921
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
603
922
|
const transport = this.device.createSendTransport({
|
|
604
923
|
...params,
|
|
@@ -610,7 +929,10 @@ export class StreamClient {
|
|
|
610
929
|
.catch(fail);
|
|
611
930
|
});
|
|
612
931
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
613
|
-
this.rpc('produce', {
|
|
932
|
+
this.rpc('produce', {
|
|
933
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
934
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
935
|
+
})
|
|
614
936
|
.then((res) => ok({ id: res.producerId }))
|
|
615
937
|
.catch(fail);
|
|
616
938
|
});
|
|
@@ -629,7 +951,7 @@ export class StreamClient {
|
|
|
629
951
|
}
|
|
630
952
|
async createRecvTransport() {
|
|
631
953
|
if (!this.device)
|
|
632
|
-
throw new
|
|
954
|
+
throw new RpcError('not-connected', 'not connected');
|
|
633
955
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
634
956
|
const transport = this.device.createRecvTransport({
|
|
635
957
|
...params,
|
|
@@ -656,12 +978,24 @@ export class StreamClient {
|
|
|
656
978
|
* kept for a reconnect's republish.
|
|
657
979
|
*/
|
|
658
980
|
async publish(stream, constraints, options) {
|
|
981
|
+
// A kind the project switched off is refused here, before anything is
|
|
982
|
+
// produced, with the code the controller would answer with.
|
|
983
|
+
const permissions = this.mediaPermissions;
|
|
984
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
985
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
986
|
+
}
|
|
987
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
988
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
989
|
+
}
|
|
990
|
+
await this.publishStream(stream, constraints, options);
|
|
991
|
+
}
|
|
992
|
+
async publishStream(stream, constraints, options) {
|
|
659
993
|
const generation = this.publishGeneration;
|
|
660
994
|
const transport = await this.ensureSendTransport();
|
|
661
995
|
// unpublish() ran first: this publish is over before it began, and must
|
|
662
996
|
// leave nothing behind that a reconnect would publish again.
|
|
663
997
|
if (generation !== this.publishGeneration)
|
|
664
|
-
throw
|
|
998
|
+
throw unpublishedWhile('stream');
|
|
665
999
|
this.localStream = stream;
|
|
666
1000
|
this.wasPublishing = true;
|
|
667
1001
|
if (constraints)
|
|
@@ -670,35 +1004,140 @@ export class StreamClient {
|
|
|
670
1004
|
this.publishOptions = { ...options };
|
|
671
1005
|
this.frameRateFallback = false;
|
|
672
1006
|
this.frameRateLowSamples = 0;
|
|
1007
|
+
this.audioCapReported = false;
|
|
1008
|
+
this.sizeCapReported = false;
|
|
673
1009
|
}
|
|
1010
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
1011
|
+
// what is still allowed goes out, and the rest is reported.
|
|
1012
|
+
const permissions = this.mediaPermissions;
|
|
674
1013
|
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);
|
|
1014
|
+
if (audio && !permissions.audio) {
|
|
1015
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
1016
|
+
}
|
|
1017
|
+
else if (audio) {
|
|
1018
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
687
1019
|
}
|
|
688
1020
|
const video = stream.getVideoTracks()[0];
|
|
689
|
-
if (video) {
|
|
1021
|
+
if (video && !permissions.video) {
|
|
1022
|
+
this.emit('error', new RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
1023
|
+
}
|
|
1024
|
+
else if (video) {
|
|
690
1025
|
// The track is read when the camera's turn comes: one handed to
|
|
691
1026
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
692
1027
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
693
1028
|
}
|
|
694
1029
|
}
|
|
1030
|
+
// ------------------------------------------------------------ audio quality
|
|
1031
|
+
/**
|
|
1032
|
+
* The microphone's audio quality: what it is being sent at while
|
|
1033
|
+
* published, else what the next publish will use, after the project's limit.
|
|
1034
|
+
*/
|
|
1035
|
+
get audioQuality() {
|
|
1036
|
+
if (this.micProducer())
|
|
1037
|
+
return this.micQuality;
|
|
1038
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
1042
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
1043
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
1044
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
1045
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
1046
|
+
* only. Resolves to the quality in use.
|
|
1047
|
+
*/
|
|
1048
|
+
async setAudioQuality(quality) {
|
|
1049
|
+
if (quality !== 'std' && quality !== 'hd')
|
|
1050
|
+
throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
1051
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
1052
|
+
throw new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
1053
|
+
}
|
|
1054
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
1055
|
+
this.audioCapReported = false;
|
|
1056
|
+
await this.micTask(async () => {
|
|
1057
|
+
const current = this.micProducer();
|
|
1058
|
+
const track = current?.track;
|
|
1059
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1060
|
+
return;
|
|
1061
|
+
await this.reproduceMic(current, track);
|
|
1062
|
+
});
|
|
1063
|
+
return this.audioQuality;
|
|
1064
|
+
}
|
|
1065
|
+
micProducer() {
|
|
1066
|
+
for (const p of this.producers.values()) {
|
|
1067
|
+
if (p.kind === 'audio' && p.appData.source === 'mic')
|
|
1068
|
+
return p;
|
|
1069
|
+
}
|
|
1070
|
+
return undefined;
|
|
1071
|
+
}
|
|
1072
|
+
micTask(task) {
|
|
1073
|
+
const run = this.micQueue.then(task, task);
|
|
1074
|
+
this.micQueue = run.catch(() => undefined);
|
|
1075
|
+
return run;
|
|
1076
|
+
}
|
|
1077
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1078
|
+
audioQualityFor() {
|
|
1079
|
+
if (this.publishOptions.audioQuality !== 'hd')
|
|
1080
|
+
return 'std';
|
|
1081
|
+
if (this.mediaPermissions.audioQualityMax === 'hd')
|
|
1082
|
+
return 'hd';
|
|
1083
|
+
if (!this.audioCapReported) {
|
|
1084
|
+
this.audioCapReported = true;
|
|
1085
|
+
this.emit('error', new RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1086
|
+
}
|
|
1087
|
+
return 'std';
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1091
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1092
|
+
* paused, on both ends.
|
|
1093
|
+
*/
|
|
1094
|
+
async produceMic(transport, track, generation) {
|
|
1095
|
+
if (generation !== this.publishGeneration)
|
|
1096
|
+
throw unpublishedWhile('microphone');
|
|
1097
|
+
const quality = this.audioQualityFor();
|
|
1098
|
+
const settings = AUDIO_SETTINGS[quality];
|
|
1099
|
+
const producer = await transport.produce({
|
|
1100
|
+
track,
|
|
1101
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1102
|
+
// every producer, and a stopped track cannot be republished.
|
|
1103
|
+
stopTracks: false,
|
|
1104
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1105
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1106
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1107
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1108
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1109
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1110
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1111
|
+
});
|
|
1112
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1113
|
+
this.micQuality = quality;
|
|
1114
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1115
|
+
await this.pauseOnServer(producer);
|
|
1116
|
+
return producer;
|
|
1117
|
+
}
|
|
1118
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1119
|
+
async reproduceMic(current, track) {
|
|
1120
|
+
const generation = this.publishGeneration;
|
|
1121
|
+
current.close();
|
|
1122
|
+
this.producers.delete(current.id);
|
|
1123
|
+
// The server allows one producer per source: the old one goes first.
|
|
1124
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1125
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1126
|
+
}
|
|
695
1127
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
696
1128
|
get videoPolicy() {
|
|
697
1129
|
return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
|
|
698
1130
|
}
|
|
699
|
-
/**
|
|
1131
|
+
/**
|
|
1132
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1133
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1134
|
+
*/
|
|
700
1135
|
get videoQuality() {
|
|
701
|
-
return
|
|
1136
|
+
return qualityAt(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1137
|
+
}
|
|
1138
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1139
|
+
get videoFrameRate() {
|
|
1140
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
702
1141
|
}
|
|
703
1142
|
/**
|
|
704
1143
|
* Change the video quality while publishing. The camera's producer is
|
|
@@ -708,6 +1147,7 @@ export class StreamClient {
|
|
|
708
1147
|
*/
|
|
709
1148
|
async setVideoQuality(quality) {
|
|
710
1149
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1150
|
+
this.sizeCapReported = false;
|
|
711
1151
|
await this.cameraTask(async () => {
|
|
712
1152
|
const current = this.cameraProducer();
|
|
713
1153
|
const track = current?.track;
|
|
@@ -717,13 +1157,25 @@ export class StreamClient {
|
|
|
717
1157
|
});
|
|
718
1158
|
return this.videoQuality;
|
|
719
1159
|
}
|
|
720
|
-
/**
|
|
1160
|
+
/**
|
|
1161
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1162
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1163
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1164
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1165
|
+
* the frame rate used.
|
|
1166
|
+
*/
|
|
721
1167
|
async setVideoFrameRate(frameRate) {
|
|
722
1168
|
if (frameRate !== 30 && frameRate !== 60)
|
|
723
1169
|
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
724
1170
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
725
1171
|
this.frameRateFallback = false;
|
|
726
1172
|
this.frameRateLowSamples = 0;
|
|
1173
|
+
this.sizeCapReported = false;
|
|
1174
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1175
|
+
this.frameRateFallback = true;
|
|
1176
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1177
|
+
return this.videoFrameRate;
|
|
1178
|
+
}
|
|
727
1179
|
await this.cameraTask(async () => {
|
|
728
1180
|
const current = this.cameraProducer();
|
|
729
1181
|
const track = current?.track;
|
|
@@ -735,7 +1187,24 @@ export class StreamClient {
|
|
|
735
1187
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
736
1188
|
await this.reproduceCamera(current, track);
|
|
737
1189
|
});
|
|
738
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1190
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1191
|
+
}
|
|
1192
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1193
|
+
allows60() {
|
|
1194
|
+
const permissions = this.mediaPermissions;
|
|
1195
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1199
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1200
|
+
*/
|
|
1201
|
+
cameraQuality(frameRate) {
|
|
1202
|
+
const permissions = this.mediaPermissions;
|
|
1203
|
+
if (frameRate === 60 && !this.sizeCapReported && above60fpsCap(this.publishOptions.quality, permissions)) {
|
|
1204
|
+
this.sizeCapReported = true;
|
|
1205
|
+
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`));
|
|
1206
|
+
}
|
|
1207
|
+
return qualityAt(this.publishOptions.quality, frameRate, permissions);
|
|
739
1208
|
}
|
|
740
1209
|
/**
|
|
741
1210
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
@@ -781,7 +1250,8 @@ export class StreamClient {
|
|
|
781
1250
|
const paused = current.paused;
|
|
782
1251
|
try {
|
|
783
1252
|
const { width = 0, height = 0 } = track.getSettings();
|
|
784
|
-
const
|
|
1253
|
+
const frameRate = this.frameRateFor(track);
|
|
1254
|
+
const next = videoEncodings(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
785
1255
|
const previousSize = current.track?.getSettings();
|
|
786
1256
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
787
1257
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -974,7 +1444,7 @@ export class StreamClient {
|
|
|
974
1444
|
*/
|
|
975
1445
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
976
1446
|
if (generation !== this.publishGeneration)
|
|
977
|
-
throw
|
|
1447
|
+
throw unpublishedWhile('camera');
|
|
978
1448
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
979
1449
|
// off. Setting the track to the camera's state first makes the producer
|
|
980
1450
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -985,8 +1455,9 @@ export class StreamClient {
|
|
|
985
1455
|
// the device, so the browser sends every layer asked for (see
|
|
986
1456
|
// videoEncodings).
|
|
987
1457
|
const { width = 0, height = 0 } = track.getSettings();
|
|
988
|
-
|
|
1458
|
+
// The frame rate first: the size the project allows depends on it.
|
|
989
1459
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1460
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
990
1461
|
const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
991
1462
|
let codecs;
|
|
992
1463
|
try {
|
|
@@ -1036,7 +1507,7 @@ export class StreamClient {
|
|
|
1036
1507
|
if (generation !== this.publishGeneration) {
|
|
1037
1508
|
producer.close();
|
|
1038
1509
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1039
|
-
throw
|
|
1510
|
+
throw unpublishedWhile(what);
|
|
1040
1511
|
}
|
|
1041
1512
|
this.producers.set(producer.id, producer);
|
|
1042
1513
|
}
|
|
@@ -1075,7 +1546,7 @@ export class StreamClient {
|
|
|
1075
1546
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1076
1547
|
reportCodecFallback(message) {
|
|
1077
1548
|
if (normalizeCodec(this.publishOptions.codec) === 'h264') {
|
|
1078
|
-
this.emit('error', new
|
|
1549
|
+
this.emit('error', new RpcError('codec-fallback', message));
|
|
1079
1550
|
}
|
|
1080
1551
|
else if (!this.codecFallbackLogged) {
|
|
1081
1552
|
this.codecFallbackLogged = true;
|
|
@@ -1133,7 +1604,7 @@ export class StreamClient {
|
|
|
1133
1604
|
const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
|
|
1134
1605
|
if (!live) {
|
|
1135
1606
|
if (!this.publishConstraints) {
|
|
1136
|
-
this.emit('error', new
|
|
1607
|
+
this.emit('error', new RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1137
1608
|
return;
|
|
1138
1609
|
}
|
|
1139
1610
|
const ended = stream;
|
|
@@ -1155,13 +1626,15 @@ export class StreamClient {
|
|
|
1155
1626
|
}
|
|
1156
1627
|
if (!stream)
|
|
1157
1628
|
return;
|
|
1158
|
-
await this.
|
|
1629
|
+
await this.publishStream(stream);
|
|
1159
1630
|
}
|
|
1160
1631
|
// ------------------------------------------------------------- screen sharing
|
|
1161
1632
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1162
1633
|
async publishScreen(options = {}) {
|
|
1163
1634
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1164
1635
|
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1636
|
+
if (!this.mediaPermissions.video)
|
|
1637
|
+
throw new RpcError('media-disabled', 'video is switched off for this project');
|
|
1165
1638
|
if (this.screenStream || this.screenPending)
|
|
1166
1639
|
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1167
1640
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
@@ -1182,7 +1655,7 @@ export class StreamClient {
|
|
|
1182
1655
|
if (!video || video.readyState !== 'live')
|
|
1183
1656
|
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1184
1657
|
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1185
|
-
throw
|
|
1658
|
+
throw unpublishedWhile('screen');
|
|
1186
1659
|
this.screenStream = stream;
|
|
1187
1660
|
this.screenMode = mode;
|
|
1188
1661
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1192,7 +1665,7 @@ export class StreamClient {
|
|
|
1192
1665
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1193
1666
|
await this.restoreScreen();
|
|
1194
1667
|
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1195
|
-
throw
|
|
1668
|
+
throw unpublishedWhile('screen');
|
|
1196
1669
|
this.screenStarted = true;
|
|
1197
1670
|
this.wasPublishing = true;
|
|
1198
1671
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1256,7 +1729,7 @@ export class StreamClient {
|
|
|
1256
1729
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1257
1730
|
const encodings = screenEncodings(width, height, this.screenMode);
|
|
1258
1731
|
// A project's explicit safety restriction also applies to screen video.
|
|
1259
|
-
const cap = topLayerSize(width, height, this.
|
|
1732
|
+
const cap = topLayerSize(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1260
1733
|
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1261
1734
|
if (cap.scale > screenScale)
|
|
1262
1735
|
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
@@ -1264,7 +1737,7 @@ export class StreamClient {
|
|
|
1264
1737
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1265
1738
|
producer.close();
|
|
1266
1739
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1267
|
-
throw
|
|
1740
|
+
throw unpublishedWhile('screen');
|
|
1268
1741
|
}
|
|
1269
1742
|
this.producers.set(producer.id, producer);
|
|
1270
1743
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1275,7 +1748,12 @@ export class StreamClient {
|
|
|
1275
1748
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1276
1749
|
appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
|
|
1277
1750
|
}));
|
|
1278
|
-
|
|
1751
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1752
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
1753
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
1754
|
+
this.emit('error', new RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
1755
|
+
audio = undefined;
|
|
1756
|
+
}
|
|
1279
1757
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1280
1758
|
await keep(await transport.produce({
|
|
1281
1759
|
track: audio, stopTracks: false,
|
|
@@ -1283,13 +1761,25 @@ export class StreamClient {
|
|
|
1283
1761
|
appData: { source: 'screen-audio' },
|
|
1284
1762
|
}));
|
|
1285
1763
|
}
|
|
1764
|
+
/**
|
|
1765
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
1766
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
1767
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
1768
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
1769
|
+
* the camera or device cannot provide it.
|
|
1770
|
+
*/
|
|
1286
1771
|
frameRateFor(track) {
|
|
1287
1772
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1288
1773
|
return 30;
|
|
1774
|
+
if (!this.allows60()) {
|
|
1775
|
+
this.frameRateFallback = true;
|
|
1776
|
+
this.emit('error', new RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1777
|
+
return 30;
|
|
1778
|
+
}
|
|
1289
1779
|
const captured = track.getSettings().frameRate;
|
|
1290
|
-
if (
|
|
1780
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1291
1781
|
this.frameRateFallback = true;
|
|
1292
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
1782
|
+
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1293
1783
|
return 30;
|
|
1294
1784
|
}
|
|
1295
1785
|
return 60;
|
|
@@ -1336,6 +1826,8 @@ export class StreamClient {
|
|
|
1336
1826
|
* the user has not interacted with.
|
|
1337
1827
|
*/
|
|
1338
1828
|
async publishMusic(input, options) {
|
|
1829
|
+
if (!this.mediaPermissions.audio)
|
|
1830
|
+
throw new RpcError('media-disabled', 'audio is switched off for this project');
|
|
1339
1831
|
const generation = this.publishGeneration;
|
|
1340
1832
|
if (this.music)
|
|
1341
1833
|
await this.stopMusic();
|
|
@@ -1346,7 +1838,7 @@ export class StreamClient {
|
|
|
1346
1838
|
// already closed it, and the music must not be remembered for a
|
|
1347
1839
|
// reconnect to publish again.
|
|
1348
1840
|
if (generation !== this.publishGeneration)
|
|
1349
|
-
throw
|
|
1841
|
+
throw unpublishedWhile('music');
|
|
1350
1842
|
}
|
|
1351
1843
|
catch (err) {
|
|
1352
1844
|
music.close();
|
|
@@ -1359,7 +1851,7 @@ export class StreamClient {
|
|
|
1359
1851
|
async produceMusic(music, generation) {
|
|
1360
1852
|
const transport = await this.ensureSendTransport();
|
|
1361
1853
|
if (generation !== this.publishGeneration)
|
|
1362
|
-
throw
|
|
1854
|
+
throw unpublishedWhile('music');
|
|
1363
1855
|
const producer = await transport.produce({
|
|
1364
1856
|
track: music.track,
|
|
1365
1857
|
stopTracks: false,
|
|
@@ -1419,13 +1911,49 @@ export class StreamClient {
|
|
|
1419
1911
|
}
|
|
1420
1912
|
async consume(remote) {
|
|
1421
1913
|
if (!this.device)
|
|
1422
|
-
throw new
|
|
1914
|
+
throw new RpcError('not-connected', 'not connected');
|
|
1423
1915
|
if (this.consumers.has(remote.producerId))
|
|
1424
1916
|
return;
|
|
1425
1917
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1426
1918
|
// consumer and shows you a delayed copy of yourself.
|
|
1427
1919
|
if (remote.identity === this.joinResult?.identity)
|
|
1428
1920
|
return;
|
|
1921
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
1922
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
1923
|
+
// asking for a refusal, and say so once per video.
|
|
1924
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
1925
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1926
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
1927
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
1928
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
1929
|
+
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`));
|
|
1930
|
+
}
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
this.videoSlots.add(remote.producerId);
|
|
1934
|
+
}
|
|
1935
|
+
try {
|
|
1936
|
+
await this.consumeNow(remote);
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
if (!this.consumers.has(remote.producerId))
|
|
1940
|
+
this.videoSlots.delete(remote.producerId);
|
|
1941
|
+
throw err;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1945
|
+
async consumeSkipped() {
|
|
1946
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
1947
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1948
|
+
if (limit !== null && this.videoSlots.size >= limit)
|
|
1949
|
+
return;
|
|
1950
|
+
this.skippedVideos.delete(producerId);
|
|
1951
|
+
await this.consume(remote);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
async consumeNow(remote) {
|
|
1955
|
+
if (!this.device)
|
|
1956
|
+
throw new RpcError('not-connected', 'not connected');
|
|
1429
1957
|
const transport = await this.ensureRecvTransport();
|
|
1430
1958
|
const params = (await this.rpc('consume', {
|
|
1431
1959
|
transportId: transport.id,
|
|
@@ -1458,6 +1986,7 @@ export class StreamClient {
|
|
|
1458
1986
|
const kind = consumer.kind;
|
|
1459
1987
|
const source = remote.source ?? defaultSource(kind);
|
|
1460
1988
|
this.remoteSources.set(remote.producerId, source);
|
|
1989
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1461
1990
|
this.emit('track', {
|
|
1462
1991
|
identity: remote.identity,
|
|
1463
1992
|
kind,
|
|
@@ -1600,8 +2129,72 @@ export class StreamClient {
|
|
|
1600
2129
|
}
|
|
1601
2130
|
promote(identity, role) { return this.rpc('promote', { identity, role }); }
|
|
1602
2131
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
1603
|
-
|
|
2132
|
+
/**
|
|
2133
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2134
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2135
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2136
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2137
|
+
* server refuses, or the connection drops with neither.
|
|
2138
|
+
*/
|
|
2139
|
+
endRoom() {
|
|
2140
|
+
return new Promise((resolve, reject) => {
|
|
2141
|
+
const ended = () => resolve({ ok: true });
|
|
2142
|
+
this.endRoomWaiters.add(ended);
|
|
2143
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
1604
2146
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2147
|
+
// ------------------------------------------------------- slots and metadata
|
|
2148
|
+
//
|
|
2149
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2150
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2151
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2152
|
+
// seed a board on join and follow claimChanged.
|
|
2153
|
+
/**
|
|
2154
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2155
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2156
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2157
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2158
|
+
*/
|
|
2159
|
+
async claim(key, identity) {
|
|
2160
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2161
|
+
}
|
|
2162
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2163
|
+
async release(key) {
|
|
2164
|
+
await this.rpc('release', { key });
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2168
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2169
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2170
|
+
*/
|
|
2171
|
+
async blockSlot(key) {
|
|
2172
|
+
await this.rpc('blockSlot', { key });
|
|
2173
|
+
}
|
|
2174
|
+
/** Unlock a slot. Moderators only. */
|
|
2175
|
+
async unblockSlot(key) {
|
|
2176
|
+
await this.rpc('unblockSlot', { key });
|
|
2177
|
+
}
|
|
2178
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2179
|
+
async setMetadata(metadata) {
|
|
2180
|
+
await this.rpc('setMetadata', { metadata });
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2184
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2185
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2186
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2187
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2188
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2189
|
+
*/
|
|
2190
|
+
async setRoomMetadata(metadata, options = {}) {
|
|
2191
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2192
|
+
metadata,
|
|
2193
|
+
...(options.replace ? { replace: true } : {}),
|
|
2194
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2195
|
+
}));
|
|
2196
|
+
return res?.metadata ?? null;
|
|
2197
|
+
}
|
|
1605
2198
|
// ------------------------------------------------------------------- chat
|
|
1606
2199
|
//
|
|
1607
2200
|
// The room's chat rides this same socket. Someone on a chat-only socket
|