@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/cjs/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StreamClient = exports.
|
|
3
|
+
exports.StreamClient = exports.CLIENT_EVENTS = exports.CONNECTION_STATES = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.tokenExpiresAt = exports.TOKEN_EXPIRING_LEAD_MS = exports.networkLevel = exports.NETWORK_QUALITY_LEVELS = exports.NETWORK_QUALITY_INTERVAL_MS = exports.resolvePermissions = exports.DEFAULT_MEDIA_PERMISSIONS = exports.AUDIO_SETTINGS = exports.AUDIO_QUALITIES = exports.isTerminalError = exports.errorInfo = exports.ERROR_CODES = exports.RpcError = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = exports.RecordingError = exports.RecordingUploader = exports.CameraRecording = void 0;
|
|
4
4
|
const recording_js_1 = require("./recording.js");
|
|
5
5
|
/**
|
|
6
6
|
* Thin client SDK over the orchestrator's signaling protocol.
|
|
@@ -10,6 +10,10 @@ const recording_js_1 = require("./recording.js");
|
|
|
10
10
|
* setup, consumer resume ordering and reconnection, each slightly wrong.
|
|
11
11
|
*/
|
|
12
12
|
const mediasoup_client_1 = require("mediasoup-client");
|
|
13
|
+
const errors_js_1 = require("./errors.js");
|
|
14
|
+
const media_permissions_js_1 = require("./media-permissions.js");
|
|
15
|
+
const network_js_1 = require("./network.js");
|
|
16
|
+
const token_js_1 = require("./token.js");
|
|
13
17
|
const device_js_1 = require("./device.js");
|
|
14
18
|
const music_js_1 = require("./music.js");
|
|
15
19
|
const quality_js_1 = require("./quality.js");
|
|
@@ -29,41 +33,46 @@ Object.defineProperty(exports, "MusicSource", { enumerable: true, get: function
|
|
|
29
33
|
var chat_js_1 = require("./chat.js");
|
|
30
34
|
Object.defineProperty(exports, "ChatClient", { enumerable: true, get: function () { return chat_js_1.ChatClient; } });
|
|
31
35
|
Object.defineProperty(exports, "ChatRpcError", { enumerable: true, get: function () { return chat_js_1.ChatRpcError; } });
|
|
36
|
+
var errors_js_2 = require("./errors.js");
|
|
37
|
+
Object.defineProperty(exports, "RpcError", { enumerable: true, get: function () { return errors_js_2.RpcError; } });
|
|
38
|
+
Object.defineProperty(exports, "ERROR_CODES", { enumerable: true, get: function () { return errors_js_2.ERROR_CODES; } });
|
|
39
|
+
Object.defineProperty(exports, "errorInfo", { enumerable: true, get: function () { return errors_js_2.errorInfo; } });
|
|
40
|
+
Object.defineProperty(exports, "isTerminalError", { enumerable: true, get: function () { return errors_js_2.isTerminalError; } });
|
|
41
|
+
var media_permissions_js_2 = require("./media-permissions.js");
|
|
42
|
+
Object.defineProperty(exports, "AUDIO_QUALITIES", { enumerable: true, get: function () { return media_permissions_js_2.AUDIO_QUALITIES; } });
|
|
43
|
+
Object.defineProperty(exports, "AUDIO_SETTINGS", { enumerable: true, get: function () { return media_permissions_js_2.AUDIO_SETTINGS; } });
|
|
44
|
+
Object.defineProperty(exports, "DEFAULT_MEDIA_PERMISSIONS", { enumerable: true, get: function () { return media_permissions_js_2.DEFAULT_MEDIA_PERMISSIONS; } });
|
|
45
|
+
Object.defineProperty(exports, "resolvePermissions", { enumerable: true, get: function () { return media_permissions_js_2.resolvePermissions; } });
|
|
46
|
+
var network_js_2 = require("./network.js");
|
|
47
|
+
Object.defineProperty(exports, "NETWORK_QUALITY_INTERVAL_MS", { enumerable: true, get: function () { return network_js_2.NETWORK_QUALITY_INTERVAL_MS; } });
|
|
48
|
+
Object.defineProperty(exports, "NETWORK_QUALITY_LEVELS", { enumerable: true, get: function () { return network_js_2.NETWORK_QUALITY_LEVELS; } });
|
|
49
|
+
Object.defineProperty(exports, "networkLevel", { enumerable: true, get: function () { return network_js_2.networkLevel; } });
|
|
50
|
+
var token_js_2 = require("./token.js");
|
|
51
|
+
Object.defineProperty(exports, "TOKEN_EXPIRING_LEAD_MS", { enumerable: true, get: function () { return token_js_2.TOKEN_EXPIRING_LEAD_MS; } });
|
|
52
|
+
Object.defineProperty(exports, "tokenExpiresAt", { enumerable: true, get: function () { return token_js_2.tokenExpiresAt; } });
|
|
32
53
|
var quality_js_2 = require("./quality.js");
|
|
33
54
|
Object.defineProperty(exports, "allowedQuality", { enumerable: true, get: function () { return quality_js_2.allowedQuality; } });
|
|
34
55
|
Object.defineProperty(exports, "layerBitrate", { enumerable: true, get: function () { return quality_js_2.layerBitrate; } });
|
|
35
56
|
Object.defineProperty(exports, "videoEncodings", { enumerable: true, get: function () { return quality_js_2.videoEncodings; } });
|
|
36
57
|
Object.defineProperty(exports, "VIDEO_QUALITIES", { enumerable: true, get: function () { return quality_js_2.VIDEO_QUALITIES; } });
|
|
58
|
+
/** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
|
|
59
|
+
exports.CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
|
|
37
60
|
const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
const TERMINAL_JOIN_ERRORS = new Set([
|
|
55
|
-
'no-room',
|
|
56
|
-
'room-ended',
|
|
57
|
-
'wrong-tenant',
|
|
58
|
-
'bad-token',
|
|
59
|
-
'no-token',
|
|
60
|
-
'forbidden',
|
|
61
|
-
// The account cannot use the service until it is topped up or restored.
|
|
62
|
-
'quota-exhausted',
|
|
63
|
-
'daily-limit-reached',
|
|
64
|
-
'monthly-limit-reached',
|
|
65
|
-
'tenant-suspended',
|
|
66
|
-
]);
|
|
61
|
+
// Every event a StreamClient emits. Typed against ClientEvents, so adding an
|
|
62
|
+
// event without listing it here (or listing one that does not exist) fails to
|
|
63
|
+
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
64
|
+
const EVENT_NAMES = {
|
|
65
|
+
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
66
|
+
track: true, trackEnded: true, trackMuted: true,
|
|
67
|
+
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
68
|
+
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
69
|
+
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
70
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
71
|
+
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
72
|
+
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
73
|
+
};
|
|
74
|
+
/** The names of every event `StreamClient.on` accepts. */
|
|
75
|
+
exports.CLIENT_EVENTS = Object.freeze(Object.keys(EVENT_NAMES));
|
|
67
76
|
const RPC_TIMEOUT_MS = 15_000;
|
|
68
77
|
const MAX_RECONNECT_ATTEMPTS = 8;
|
|
69
78
|
/** Server close code meaning "another socket took over this identity". */
|
|
@@ -78,9 +87,29 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
78
87
|
*/
|
|
79
88
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
80
89
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
81
|
-
const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published
|
|
90
|
+
const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
91
|
+
/**
|
|
92
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
93
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
94
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
95
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
96
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
97
|
+
* audio go out as they always did.
|
|
98
|
+
*/
|
|
99
|
+
function declareAudioQuality(rtpParameters, appData) {
|
|
100
|
+
const quality = appData.audioQuality;
|
|
101
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
|
|
102
|
+
return rtpParameters;
|
|
103
|
+
const maxaveragebitrate = media_permissions_js_1.AUDIO_SETTINGS[quality].maxBitrate;
|
|
104
|
+
return {
|
|
105
|
+
...rtpParameters,
|
|
106
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
107
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
82
110
|
class StreamClient {
|
|
83
111
|
wsUrl;
|
|
112
|
+
options;
|
|
84
113
|
ws = null;
|
|
85
114
|
device = null;
|
|
86
115
|
sendTransport = null;
|
|
@@ -150,10 +179,31 @@ class StreamClient {
|
|
|
150
179
|
codecFallbackLogged = false;
|
|
151
180
|
sendStatsBaseline = new Map();
|
|
152
181
|
recvStatsBaseline = new Map();
|
|
153
|
-
limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
|
|
154
|
-
|
|
182
|
+
limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
183
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
184
|
+
state = 'idle';
|
|
185
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
186
|
+
tokenExpiry;
|
|
187
|
+
tokenTimer = null;
|
|
188
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
189
|
+
remoteInfo = new Map();
|
|
190
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
191
|
+
skippedVideos = new Map();
|
|
192
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
193
|
+
videoSlots = new Set();
|
|
194
|
+
/** The quality the microphone producer was made with. */
|
|
195
|
+
micQuality = 'std';
|
|
196
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
197
|
+
micQueue = Promise.resolve();
|
|
198
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
199
|
+
audioCapReported = false;
|
|
200
|
+
sizeCapReported = false;
|
|
201
|
+
networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
202
|
+
constructor(wsUrl, token, options = {}) {
|
|
155
203
|
this.wsUrl = wsUrl;
|
|
204
|
+
this.options = options;
|
|
156
205
|
this.token = token;
|
|
206
|
+
this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
|
|
157
207
|
}
|
|
158
208
|
on(event, fn) {
|
|
159
209
|
let set = this.listeners.get(event);
|
|
@@ -162,15 +212,45 @@ class StreamClient {
|
|
|
162
212
|
this.listeners.set(event, set);
|
|
163
213
|
}
|
|
164
214
|
set.add(fn);
|
|
165
|
-
// videoLimited
|
|
215
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
216
|
+
// they run only while heard.
|
|
166
217
|
if (event === 'videoLimited')
|
|
167
218
|
this.syncLimitWatch();
|
|
219
|
+
if (event === 'networkQuality')
|
|
220
|
+
this.syncNetworkWatch();
|
|
168
221
|
return () => {
|
|
169
222
|
set.delete(fn);
|
|
170
223
|
if (event === 'videoLimited')
|
|
171
224
|
this.syncLimitWatch();
|
|
225
|
+
if (event === 'networkQuality')
|
|
226
|
+
this.syncNetworkWatch();
|
|
172
227
|
};
|
|
173
228
|
}
|
|
229
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
230
|
+
get connectionState() {
|
|
231
|
+
return this.state;
|
|
232
|
+
}
|
|
233
|
+
setState(next) {
|
|
234
|
+
if (next === this.state)
|
|
235
|
+
return;
|
|
236
|
+
const previous = this.state;
|
|
237
|
+
this.state = next;
|
|
238
|
+
if (next === 'closed')
|
|
239
|
+
this.stopTimers();
|
|
240
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
241
|
+
}
|
|
242
|
+
/** Over for good: the state, then the event that says why. */
|
|
243
|
+
finish(reason) {
|
|
244
|
+
this.closedByUs = true;
|
|
245
|
+
this.setState('closed');
|
|
246
|
+
this.emit('closed', reason);
|
|
247
|
+
}
|
|
248
|
+
stopTimers() {
|
|
249
|
+
if (this.tokenTimer)
|
|
250
|
+
clearTimeout(this.tokenTimer);
|
|
251
|
+
this.tokenTimer = null;
|
|
252
|
+
this.networkWatch.set(false);
|
|
253
|
+
}
|
|
174
254
|
listenerCount(event) {
|
|
175
255
|
return this.listeners.get(event)?.size ?? 0;
|
|
176
256
|
}
|
|
@@ -187,6 +267,8 @@ class StreamClient {
|
|
|
187
267
|
// ------------------------------------------------------------- connection
|
|
188
268
|
async connect() {
|
|
189
269
|
let result;
|
|
270
|
+
if (this.state === 'idle' || this.state === 'closed')
|
|
271
|
+
this.setState('connecting');
|
|
190
272
|
try {
|
|
191
273
|
await this.openSocket();
|
|
192
274
|
result = (await this.rpc('join', { token: this.token }));
|
|
@@ -198,8 +280,11 @@ class StreamClient {
|
|
|
198
280
|
// A session that never got going: the app has the error. Left open,
|
|
199
281
|
// the socket would be closed by the server's join deadline instead, and
|
|
200
282
|
// that close would start reconnecting a session that never began.
|
|
201
|
-
if (!this.joinedOnce)
|
|
283
|
+
if (!this.joinedOnce) {
|
|
202
284
|
this.detachSocket();
|
|
285
|
+
if (this.state === 'connecting')
|
|
286
|
+
this.setState('idle');
|
|
287
|
+
}
|
|
203
288
|
throw err;
|
|
204
289
|
}
|
|
205
290
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -208,6 +293,9 @@ class StreamClient {
|
|
|
208
293
|
// retried about twice a second, forever.
|
|
209
294
|
this.reconnectAttempt = 0;
|
|
210
295
|
this.joinedOnce = true;
|
|
296
|
+
this.skippedVideos.clear();
|
|
297
|
+
this.setState('connected');
|
|
298
|
+
this.scheduleTokenExpiring();
|
|
211
299
|
// A role or mute can change while the socket is away. The fresh roster
|
|
212
300
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
213
301
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
@@ -242,11 +330,17 @@ class StreamClient {
|
|
|
242
330
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
243
331
|
await this.republish().catch((err) => this.emit('error', err));
|
|
244
332
|
}
|
|
333
|
+
this.syncNetworkWatch();
|
|
245
334
|
return result;
|
|
246
335
|
}
|
|
247
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
338
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
339
|
+
* WebRTC, can stand one in.
|
|
340
|
+
*/
|
|
248
341
|
createDevice() {
|
|
249
|
-
|
|
342
|
+
const { handlerName } = this.options;
|
|
343
|
+
return handlerName ? new mediasoup_client_1.Device({ handlerName }) : new mediasoup_client_1.Device();
|
|
250
344
|
}
|
|
251
345
|
openSocket() {
|
|
252
346
|
// Abandon any previous socket first. Its close handler checks identity
|
|
@@ -254,15 +348,13 @@ class StreamClient {
|
|
|
254
348
|
// reconnect -- which is what let multiple reconnect chains pile up.
|
|
255
349
|
this.detachSocket();
|
|
256
350
|
return new Promise((resolve, reject) => {
|
|
257
|
-
const
|
|
258
|
-
url.searchParams.set('token', this.token);
|
|
259
|
-
const ws = new WebSocket(url.toString());
|
|
351
|
+
const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
|
|
260
352
|
this.ws = ws;
|
|
261
353
|
const onOpen = () => {
|
|
262
354
|
this.emit('connected');
|
|
263
355
|
resolve();
|
|
264
356
|
};
|
|
265
|
-
const onError = () => reject(new
|
|
357
|
+
const onError = () => reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
|
|
266
358
|
ws.addEventListener('open', onOpen, { once: true });
|
|
267
359
|
ws.addEventListener('error', onError, { once: true });
|
|
268
360
|
ws.addEventListener('message', (ev) => {
|
|
@@ -275,33 +367,33 @@ class StreamClient {
|
|
|
275
367
|
if (this.ws !== ws)
|
|
276
368
|
return;
|
|
277
369
|
this.ws = null;
|
|
278
|
-
this.failAllPending(new
|
|
370
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
|
|
279
371
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
280
372
|
if (this.closedByUs)
|
|
281
373
|
return;
|
|
282
374
|
if (ev.code === CLOSE_REPLACED) {
|
|
283
375
|
// Another session for this identity is live. Reconnecting would
|
|
284
376
|
// displace it, and it would displace us right back, forever.
|
|
285
|
-
this.emit('error', new
|
|
286
|
-
this.closedByUs = true;
|
|
377
|
+
this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
|
|
287
378
|
// The producers outlive this close until the app calls close(); the
|
|
288
379
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
289
380
|
this.limitWatch.reset();
|
|
290
|
-
this.
|
|
381
|
+
this.finish('replaced');
|
|
291
382
|
return;
|
|
292
383
|
}
|
|
293
384
|
if (ev.code === CLOSE_ENDED) {
|
|
294
385
|
// Normally roomClosed has already ended the session; this covers a
|
|
295
386
|
// close that arrived without it.
|
|
296
|
-
this.closedByUs = true;
|
|
297
387
|
this.limitWatch.reset();
|
|
298
|
-
this.
|
|
388
|
+
this.finish('room-closed');
|
|
299
389
|
return;
|
|
300
390
|
}
|
|
301
391
|
// Before a first successful join the app is still waiting on
|
|
302
392
|
// connect(), which reports the failure; there is nothing to restore.
|
|
303
393
|
if (!this.joinedOnce)
|
|
304
394
|
return;
|
|
395
|
+
this.setState('reconnecting');
|
|
396
|
+
this.networkWatch.set(false);
|
|
305
397
|
void this.scheduleReconnect();
|
|
306
398
|
});
|
|
307
399
|
});
|
|
@@ -334,9 +426,8 @@ class StreamClient {
|
|
|
334
426
|
while (!this.closedByUs) {
|
|
335
427
|
this.reconnectAttempt += 1;
|
|
336
428
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
337
|
-
this.emit('error', new
|
|
338
|
-
this.
|
|
339
|
-
this.emit('closed', 'gave-up');
|
|
429
|
+
this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
430
|
+
this.finish('gave-up');
|
|
340
431
|
return;
|
|
341
432
|
}
|
|
342
433
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -355,12 +446,11 @@ class StreamClient {
|
|
|
355
446
|
}
|
|
356
447
|
catch (err) {
|
|
357
448
|
this.emit('error', err);
|
|
358
|
-
// Stop on failures a retry cannot resolve
|
|
359
|
-
// ended produces one error per
|
|
360
|
-
// whatever actually went wrong.
|
|
361
|
-
if (err instanceof RpcError &&
|
|
362
|
-
this.
|
|
363
|
-
this.emit('closed', 'gave-up');
|
|
449
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
450
|
+
// codes). Hammering a room that has ended produces one error per
|
|
451
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
452
|
+
if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
|
|
453
|
+
this.finish('gave-up');
|
|
364
454
|
return;
|
|
365
455
|
}
|
|
366
456
|
}
|
|
@@ -387,6 +477,7 @@ class StreamClient {
|
|
|
387
477
|
this.music = null;
|
|
388
478
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
389
479
|
this.limitWatch.reset();
|
|
480
|
+
this.skippedVideos.clear();
|
|
390
481
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
391
482
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
392
483
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -394,6 +485,7 @@ class StreamClient {
|
|
|
394
485
|
const ws = this.ws;
|
|
395
486
|
this.ws = null;
|
|
396
487
|
ws?.close(1000, 'client closed');
|
|
488
|
+
this.setState('closed');
|
|
397
489
|
if (wasOpen)
|
|
398
490
|
this.emit('closed', 'left');
|
|
399
491
|
}
|
|
@@ -401,6 +493,9 @@ class StreamClient {
|
|
|
401
493
|
for (const c of this.consumers.values())
|
|
402
494
|
c.close();
|
|
403
495
|
this.consumers.clear();
|
|
496
|
+
this.remoteInfo.clear();
|
|
497
|
+
this.skippedVideos.clear();
|
|
498
|
+
this.videoSlots.clear();
|
|
404
499
|
for (const p of this.producers.values())
|
|
405
500
|
p.close();
|
|
406
501
|
this.producers.clear();
|
|
@@ -417,22 +512,137 @@ class StreamClient {
|
|
|
417
512
|
this.sendTransportPending = null;
|
|
418
513
|
this.recvTransportPending = null;
|
|
419
514
|
this.device = null;
|
|
515
|
+
this.syncNetworkWatch();
|
|
420
516
|
}
|
|
421
|
-
/**
|
|
517
|
+
/**
|
|
518
|
+
* Everything currently known about the room, for rendering: the join
|
|
519
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
520
|
+
* metadata, slots and room metadata follow the events.
|
|
521
|
+
*/
|
|
422
522
|
get info() {
|
|
423
523
|
return this.joinResult;
|
|
424
524
|
}
|
|
525
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
526
|
+
get participants() {
|
|
527
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
528
|
+
}
|
|
529
|
+
/** Every track this client is receiving now. */
|
|
530
|
+
get remoteTracks() {
|
|
531
|
+
const out = [];
|
|
532
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
533
|
+
const info = this.remoteInfo.get(producerId);
|
|
534
|
+
if (!info || consumer.closed)
|
|
535
|
+
continue;
|
|
536
|
+
out.push({
|
|
537
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
538
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* What this project lets this client publish: from the join result's
|
|
545
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
546
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
547
|
+
* or caps anything else with the server's own codes.
|
|
548
|
+
*/
|
|
549
|
+
get mediaPermissions() {
|
|
550
|
+
return (0, media_permissions_js_1.resolvePermissions)(this.joinResult);
|
|
551
|
+
}
|
|
552
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
553
|
+
get billingMode() {
|
|
554
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
555
|
+
}
|
|
556
|
+
// ------------------------------------------------------------ token refresh
|
|
557
|
+
/**
|
|
558
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
559
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
560
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
561
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
562
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
563
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
564
|
+
*/
|
|
565
|
+
async updateToken(token) {
|
|
566
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
567
|
+
throw new TypeError('updateToken needs a token');
|
|
568
|
+
if (this.state === 'closed')
|
|
569
|
+
throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
|
|
570
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
571
|
+
try {
|
|
572
|
+
const res = (await this.rpc('refreshToken', { token }));
|
|
573
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
574
|
+
this.setToken(token, at);
|
|
575
|
+
return { expiresAt: this.tokenExpiry };
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
579
|
+
// during the call is reconnecting: either way keep the token for the
|
|
580
|
+
// next join, which checks it.
|
|
581
|
+
if (!(err instanceof errors_js_1.RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
this.setToken(token, null);
|
|
586
|
+
return { expiresAt: this.tokenExpiry };
|
|
587
|
+
}
|
|
588
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
589
|
+
setToken(token, expiresAt) {
|
|
590
|
+
this.token = token;
|
|
591
|
+
this.tokenExpiry = expiresAt ?? (0, token_js_1.tokenExpiresAt)(token);
|
|
592
|
+
this.scheduleTokenExpiring();
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
596
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
597
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
598
|
+
*/
|
|
599
|
+
scheduleTokenExpiring() {
|
|
600
|
+
if (this.tokenTimer)
|
|
601
|
+
clearTimeout(this.tokenTimer);
|
|
602
|
+
this.tokenTimer = null;
|
|
603
|
+
const expiresAt = this.tokenExpiry;
|
|
604
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
|
|
605
|
+
return;
|
|
606
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
607
|
+
const delay = Math.min(Math.max(0, expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
608
|
+
this.tokenTimer = setTimeout(() => {
|
|
609
|
+
this.tokenTimer = null;
|
|
610
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
|
|
611
|
+
return;
|
|
612
|
+
if (expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
613
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
617
|
+
}, delay);
|
|
618
|
+
}
|
|
619
|
+
// ------------------------------------------------------------ network quality
|
|
620
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
621
|
+
syncNetworkWatch() {
|
|
622
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
623
|
+
}
|
|
624
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
625
|
+
async transportStats() {
|
|
626
|
+
const out = new Map();
|
|
627
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
628
|
+
if (!transport || transport.closed)
|
|
629
|
+
continue;
|
|
630
|
+
const report = await transport.getStats();
|
|
631
|
+
out.set(transport.id, report.values());
|
|
632
|
+
}
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
425
635
|
// -------------------------------------------------------------------- rpc
|
|
426
636
|
rpc(method, params = {}) {
|
|
427
637
|
return new Promise((resolve, reject) => {
|
|
428
638
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
429
|
-
reject(new
|
|
639
|
+
reject(new errors_js_1.RpcError('not-connected', 'socket is not open'));
|
|
430
640
|
return;
|
|
431
641
|
}
|
|
432
642
|
const id = this.nextId++;
|
|
433
643
|
const timer = setTimeout(() => {
|
|
434
644
|
this.pending.delete(id);
|
|
435
|
-
reject(new
|
|
645
|
+
reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
|
|
436
646
|
}, RPC_TIMEOUT_MS);
|
|
437
647
|
this.pending.set(id, { resolve, reject, timer });
|
|
438
648
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -463,7 +673,7 @@ class StreamClient {
|
|
|
463
673
|
p.resolve(msg.result);
|
|
464
674
|
else {
|
|
465
675
|
const e = msg.error;
|
|
466
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
676
|
+
p.reject(new errors_js_1.RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
467
677
|
}
|
|
468
678
|
return;
|
|
469
679
|
}
|
|
@@ -481,17 +691,27 @@ class StreamClient {
|
|
|
481
691
|
this.preferredLayers.delete(producerId);
|
|
482
692
|
this.receiveQualities.delete(producerId);
|
|
483
693
|
this.remoteVideoLayers.delete(producerId);
|
|
694
|
+
this.skippedVideos.delete(producerId);
|
|
484
695
|
const consumer = this.consumers.get(producerId);
|
|
485
696
|
if (!consumer)
|
|
486
697
|
break; // never consumed it, or already cleaned up
|
|
698
|
+
this.videoSlots.delete(producerId);
|
|
487
699
|
consumer.close();
|
|
488
700
|
this.consumers.delete(producerId);
|
|
489
701
|
this.remoteSources.delete(producerId);
|
|
702
|
+
this.remoteInfo.delete(producerId);
|
|
490
703
|
this.emit('trackEnded', { producerId, identity });
|
|
704
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
705
|
+
// waiting takes it.
|
|
706
|
+
if (consumer.kind === 'video')
|
|
707
|
+
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
491
708
|
break;
|
|
492
709
|
}
|
|
493
710
|
case 'producerPaused': {
|
|
494
711
|
const p = data;
|
|
712
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
713
|
+
if (info)
|
|
714
|
+
info.paused = Boolean(p.paused);
|
|
495
715
|
this.emit('trackMuted', {
|
|
496
716
|
identity: p.identity,
|
|
497
717
|
kind: p.kind,
|
|
@@ -501,27 +721,40 @@ class StreamClient {
|
|
|
501
721
|
});
|
|
502
722
|
break;
|
|
503
723
|
}
|
|
504
|
-
case 'participantJoined':
|
|
505
|
-
|
|
724
|
+
case 'participantJoined': {
|
|
725
|
+
const p = data;
|
|
726
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
727
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
728
|
+
this.emit('participantJoined', p);
|
|
506
729
|
break;
|
|
730
|
+
}
|
|
507
731
|
case 'participantMuted': {
|
|
508
732
|
const wire = data;
|
|
509
733
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
});
|
|
734
|
+
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
735
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
736
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
737
|
+
}));
|
|
515
738
|
this.emit('participantMuted', e);
|
|
516
739
|
break;
|
|
517
740
|
}
|
|
518
|
-
case '
|
|
519
|
-
|
|
741
|
+
case 'participantUpdated': {
|
|
742
|
+
const e = data;
|
|
743
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
744
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case 'participantLeft': {
|
|
748
|
+
const e = data;
|
|
749
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
750
|
+
this.emit('participantLeft', e);
|
|
520
751
|
break;
|
|
752
|
+
}
|
|
521
753
|
case 'roleChanged': {
|
|
522
754
|
const e = data;
|
|
523
755
|
if (e.identity === this.joinResult?.identity)
|
|
524
756
|
this.joinResult.role = e.role;
|
|
757
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
525
758
|
this.emit('roleChanged', e);
|
|
526
759
|
// Demoted: the server has already closed our producers. Release the
|
|
527
760
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -532,9 +765,13 @@ class StreamClient {
|
|
|
532
765
|
break;
|
|
533
766
|
}
|
|
534
767
|
case 'handRaised':
|
|
535
|
-
case 'handLowered':
|
|
768
|
+
case 'handLowered': {
|
|
769
|
+
const e = data;
|
|
770
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
771
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
536
772
|
this.emit('handRaised', data);
|
|
537
773
|
break;
|
|
774
|
+
}
|
|
538
775
|
case 'forceMuted': {
|
|
539
776
|
// The server has already paused that kind (an audio mute includes
|
|
540
777
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -547,20 +784,33 @@ class StreamClient {
|
|
|
547
784
|
this.emit('forceMuted', e);
|
|
548
785
|
break;
|
|
549
786
|
}
|
|
787
|
+
case 'claimChanged': {
|
|
788
|
+
const e = data;
|
|
789
|
+
this.applyClaim(e);
|
|
790
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
case 'roomMetadataChanged': {
|
|
794
|
+
const e = data;
|
|
795
|
+
if (this.joinResult)
|
|
796
|
+
this.joinResult.metadata = e.metadata;
|
|
797
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
550
800
|
case 'tokenRefreshed': {
|
|
551
801
|
// A room outlives its join token many times over. The server hands us a
|
|
552
802
|
// fresh one periodically so a reconnect at any point still works --
|
|
553
803
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
554
804
|
const e = data;
|
|
555
805
|
if (e.token)
|
|
556
|
-
this.token
|
|
806
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
557
807
|
break;
|
|
558
808
|
}
|
|
559
809
|
case 'mustReconnect': {
|
|
560
810
|
const e = data;
|
|
561
811
|
// A promotion hands us a fresh token carrying the new role.
|
|
562
812
|
if (e.token)
|
|
563
|
-
this.
|
|
813
|
+
this.setToken(e.token, null);
|
|
564
814
|
this.emit('mustReconnect', e);
|
|
565
815
|
if (e.retry) {
|
|
566
816
|
this.teardownMedia();
|
|
@@ -576,16 +826,22 @@ class StreamClient {
|
|
|
576
826
|
case 'usageLimit':
|
|
577
827
|
this.emit('usageLimit', d);
|
|
578
828
|
break;
|
|
829
|
+
case 'walletLow': {
|
|
830
|
+
const e = data;
|
|
831
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
832
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
579
835
|
case 'roomClosed':
|
|
580
836
|
this.closedByUs = true;
|
|
581
837
|
this.emit('roomClosed', d);
|
|
582
|
-
this.
|
|
838
|
+
this.finish('room-closed');
|
|
583
839
|
this.close();
|
|
584
840
|
break;
|
|
585
841
|
case 'kicked':
|
|
586
842
|
this.closedByUs = true;
|
|
587
843
|
this.emit('kicked', d);
|
|
588
|
-
this.
|
|
844
|
+
this.finish('kicked');
|
|
589
845
|
this.close();
|
|
590
846
|
break;
|
|
591
847
|
case 'message':
|
|
@@ -604,6 +860,25 @@ class StreamClient {
|
|
|
604
860
|
break;
|
|
605
861
|
}
|
|
606
862
|
}
|
|
863
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
864
|
+
updateRoster(change) {
|
|
865
|
+
if (this.joinResult)
|
|
866
|
+
this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
867
|
+
}
|
|
868
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
869
|
+
applyClaim(e) {
|
|
870
|
+
const join = this.joinResult;
|
|
871
|
+
if (!join)
|
|
872
|
+
return;
|
|
873
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
874
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
875
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity)
|
|
876
|
+
claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
877
|
+
if (e.state === 'blocked')
|
|
878
|
+
blocked = [...blocked, e.key];
|
|
879
|
+
join.claims = claims;
|
|
880
|
+
join.blockedSlots = blocked;
|
|
881
|
+
}
|
|
607
882
|
// ------------------------------------------------------------------ media
|
|
608
883
|
ensureSendTransport() {
|
|
609
884
|
if (this.sendTransport)
|
|
@@ -617,7 +892,7 @@ class StreamClient {
|
|
|
617
892
|
}
|
|
618
893
|
async createSendTransport() {
|
|
619
894
|
if (!this.device)
|
|
620
|
-
throw new
|
|
895
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
621
896
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
622
897
|
const transport = this.device.createSendTransport({
|
|
623
898
|
...params,
|
|
@@ -629,7 +904,10 @@ class StreamClient {
|
|
|
629
904
|
.catch(fail);
|
|
630
905
|
});
|
|
631
906
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
632
|
-
this.rpc('produce', {
|
|
907
|
+
this.rpc('produce', {
|
|
908
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
909
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
910
|
+
})
|
|
633
911
|
.then((res) => ok({ id: res.producerId }))
|
|
634
912
|
.catch(fail);
|
|
635
913
|
});
|
|
@@ -648,7 +926,7 @@ class StreamClient {
|
|
|
648
926
|
}
|
|
649
927
|
async createRecvTransport() {
|
|
650
928
|
if (!this.device)
|
|
651
|
-
throw new
|
|
929
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
652
930
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
653
931
|
const transport = this.device.createRecvTransport({
|
|
654
932
|
...params,
|
|
@@ -675,12 +953,24 @@ class StreamClient {
|
|
|
675
953
|
* kept for a reconnect's republish.
|
|
676
954
|
*/
|
|
677
955
|
async publish(stream, constraints, options) {
|
|
956
|
+
// A kind the project switched off is refused here, before anything is
|
|
957
|
+
// produced, with the code the controller would answer with.
|
|
958
|
+
const permissions = this.mediaPermissions;
|
|
959
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
960
|
+
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
961
|
+
}
|
|
962
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
963
|
+
throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
|
|
964
|
+
}
|
|
965
|
+
await this.publishStream(stream, constraints, options);
|
|
966
|
+
}
|
|
967
|
+
async publishStream(stream, constraints, options) {
|
|
678
968
|
const generation = this.publishGeneration;
|
|
679
969
|
const transport = await this.ensureSendTransport();
|
|
680
970
|
// unpublish() ran first: this publish is over before it began, and must
|
|
681
971
|
// leave nothing behind that a reconnect would publish again.
|
|
682
972
|
if (generation !== this.publishGeneration)
|
|
683
|
-
throw
|
|
973
|
+
throw unpublishedWhile('stream');
|
|
684
974
|
this.localStream = stream;
|
|
685
975
|
this.wasPublishing = true;
|
|
686
976
|
if (constraints)
|
|
@@ -689,35 +979,140 @@ class StreamClient {
|
|
|
689
979
|
this.publishOptions = { ...options };
|
|
690
980
|
this.frameRateFallback = false;
|
|
691
981
|
this.frameRateLowSamples = 0;
|
|
982
|
+
this.audioCapReported = false;
|
|
983
|
+
this.sizeCapReported = false;
|
|
692
984
|
}
|
|
985
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
986
|
+
// what is still allowed goes out, and the rest is reported.
|
|
987
|
+
const permissions = this.mediaPermissions;
|
|
693
988
|
const audio = stream.getAudioTracks()[0];
|
|
694
|
-
if (audio) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
stopTracks: false,
|
|
700
|
-
codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
|
|
701
|
-
appData: { source: 'mic' },
|
|
702
|
-
});
|
|
703
|
-
await this.keepProducer(producer, generation, 'microphone');
|
|
704
|
-
// A microphone handed over switched off, or muted before a reconnect.
|
|
705
|
-
await this.pauseOnServer(producer);
|
|
989
|
+
if (audio && !permissions.audio) {
|
|
990
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
991
|
+
}
|
|
992
|
+
else if (audio) {
|
|
993
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
706
994
|
}
|
|
707
995
|
const video = stream.getVideoTracks()[0];
|
|
708
|
-
if (video) {
|
|
996
|
+
if (video && !permissions.video) {
|
|
997
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
998
|
+
}
|
|
999
|
+
else if (video) {
|
|
709
1000
|
// The track is read when the camera's turn comes: one handed to
|
|
710
1001
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
711
1002
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
712
1003
|
}
|
|
713
1004
|
}
|
|
1005
|
+
// ------------------------------------------------------------ audio quality
|
|
1006
|
+
/**
|
|
1007
|
+
* The microphone's audio quality: what it is being sent at while
|
|
1008
|
+
* published, else what the next publish will use, after the project's limit.
|
|
1009
|
+
*/
|
|
1010
|
+
get audioQuality() {
|
|
1011
|
+
if (this.micProducer())
|
|
1012
|
+
return this.micQuality;
|
|
1013
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
1017
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
1018
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
1019
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
1020
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
1021
|
+
* only. Resolves to the quality in use.
|
|
1022
|
+
*/
|
|
1023
|
+
async setAudioQuality(quality) {
|
|
1024
|
+
if (quality !== 'std' && quality !== 'hd')
|
|
1025
|
+
throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
1026
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
1027
|
+
throw new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
1028
|
+
}
|
|
1029
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
1030
|
+
this.audioCapReported = false;
|
|
1031
|
+
await this.micTask(async () => {
|
|
1032
|
+
const current = this.micProducer();
|
|
1033
|
+
const track = current?.track;
|
|
1034
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1035
|
+
return;
|
|
1036
|
+
await this.reproduceMic(current, track);
|
|
1037
|
+
});
|
|
1038
|
+
return this.audioQuality;
|
|
1039
|
+
}
|
|
1040
|
+
micProducer() {
|
|
1041
|
+
for (const p of this.producers.values()) {
|
|
1042
|
+
if (p.kind === 'audio' && p.appData.source === 'mic')
|
|
1043
|
+
return p;
|
|
1044
|
+
}
|
|
1045
|
+
return undefined;
|
|
1046
|
+
}
|
|
1047
|
+
micTask(task) {
|
|
1048
|
+
const run = this.micQueue.then(task, task);
|
|
1049
|
+
this.micQueue = run.catch(() => undefined);
|
|
1050
|
+
return run;
|
|
1051
|
+
}
|
|
1052
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1053
|
+
audioQualityFor() {
|
|
1054
|
+
if (this.publishOptions.audioQuality !== 'hd')
|
|
1055
|
+
return 'std';
|
|
1056
|
+
if (this.mediaPermissions.audioQualityMax === 'hd')
|
|
1057
|
+
return 'hd';
|
|
1058
|
+
if (!this.audioCapReported) {
|
|
1059
|
+
this.audioCapReported = true;
|
|
1060
|
+
this.emit('error', new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1061
|
+
}
|
|
1062
|
+
return 'std';
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1066
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1067
|
+
* paused, on both ends.
|
|
1068
|
+
*/
|
|
1069
|
+
async produceMic(transport, track, generation) {
|
|
1070
|
+
if (generation !== this.publishGeneration)
|
|
1071
|
+
throw unpublishedWhile('microphone');
|
|
1072
|
+
const quality = this.audioQualityFor();
|
|
1073
|
+
const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
|
|
1074
|
+
const producer = await transport.produce({
|
|
1075
|
+
track,
|
|
1076
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1077
|
+
// every producer, and a stopped track cannot be republished.
|
|
1078
|
+
stopTracks: false,
|
|
1079
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1080
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1081
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1082
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1083
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1084
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1085
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1086
|
+
});
|
|
1087
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1088
|
+
this.micQuality = quality;
|
|
1089
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1090
|
+
await this.pauseOnServer(producer);
|
|
1091
|
+
return producer;
|
|
1092
|
+
}
|
|
1093
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1094
|
+
async reproduceMic(current, track) {
|
|
1095
|
+
const generation = this.publishGeneration;
|
|
1096
|
+
current.close();
|
|
1097
|
+
this.producers.delete(current.id);
|
|
1098
|
+
// The server allows one producer per source: the old one goes first.
|
|
1099
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1100
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1101
|
+
}
|
|
714
1102
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
715
1103
|
get videoPolicy() {
|
|
716
1104
|
return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
|
|
717
1105
|
}
|
|
718
|
-
/**
|
|
1106
|
+
/**
|
|
1107
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1108
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1109
|
+
*/
|
|
719
1110
|
get videoQuality() {
|
|
720
|
-
return (0,
|
|
1111
|
+
return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1112
|
+
}
|
|
1113
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1114
|
+
get videoFrameRate() {
|
|
1115
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
721
1116
|
}
|
|
722
1117
|
/**
|
|
723
1118
|
* Change the video quality while publishing. The camera's producer is
|
|
@@ -727,6 +1122,7 @@ class StreamClient {
|
|
|
727
1122
|
*/
|
|
728
1123
|
async setVideoQuality(quality) {
|
|
729
1124
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1125
|
+
this.sizeCapReported = false;
|
|
730
1126
|
await this.cameraTask(async () => {
|
|
731
1127
|
const current = this.cameraProducer();
|
|
732
1128
|
const track = current?.track;
|
|
@@ -736,25 +1132,54 @@ class StreamClient {
|
|
|
736
1132
|
});
|
|
737
1133
|
return this.videoQuality;
|
|
738
1134
|
}
|
|
739
|
-
/**
|
|
1135
|
+
/**
|
|
1136
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1137
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1138
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1139
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1140
|
+
* the frame rate used.
|
|
1141
|
+
*/
|
|
740
1142
|
async setVideoFrameRate(frameRate) {
|
|
741
1143
|
if (frameRate !== 30 && frameRate !== 60)
|
|
742
1144
|
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
743
1145
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
744
1146
|
this.frameRateFallback = false;
|
|
745
1147
|
this.frameRateLowSamples = 0;
|
|
1148
|
+
this.sizeCapReported = false;
|
|
1149
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1150
|
+
this.frameRateFallback = true;
|
|
1151
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1152
|
+
return this.videoFrameRate;
|
|
1153
|
+
}
|
|
746
1154
|
await this.cameraTask(async () => {
|
|
747
1155
|
const current = this.cameraProducer();
|
|
748
1156
|
const track = current?.track;
|
|
749
1157
|
if (!current || !track || track.readyState !== 'live')
|
|
750
1158
|
return;
|
|
751
1159
|
try {
|
|
752
|
-
await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
|
|
1160
|
+
await track.applyConstraints?.({ ...track.getConstraints?.(), frameRate: { ideal: frameRate, max: frameRate } });
|
|
753
1161
|
}
|
|
754
1162
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
755
1163
|
await this.reproduceCamera(current, track);
|
|
756
1164
|
});
|
|
757
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1165
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1166
|
+
}
|
|
1167
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1168
|
+
allows60() {
|
|
1169
|
+
const permissions = this.mediaPermissions;
|
|
1170
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1174
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1175
|
+
*/
|
|
1176
|
+
cameraQuality(frameRate) {
|
|
1177
|
+
const permissions = this.mediaPermissions;
|
|
1178
|
+
if (frameRate === 60 && !this.sizeCapReported && (0, media_permissions_js_1.above60fpsCap)(this.publishOptions.quality, permissions)) {
|
|
1179
|
+
this.sizeCapReported = true;
|
|
1180
|
+
this.emit('error', new errors_js_1.RpcError('video-quality-not-allowed', `${this.publishOptions.quality} at 60 fps is not enabled for this project; publishing ${permissions.maxVideoQuality60} at 60 fps`));
|
|
1181
|
+
}
|
|
1182
|
+
return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, frameRate, permissions);
|
|
758
1183
|
}
|
|
759
1184
|
/**
|
|
760
1185
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
@@ -800,7 +1225,8 @@ class StreamClient {
|
|
|
800
1225
|
const paused = current.paused;
|
|
801
1226
|
try {
|
|
802
1227
|
const { width = 0, height = 0 } = track.getSettings();
|
|
803
|
-
const
|
|
1228
|
+
const frameRate = this.frameRateFor(track);
|
|
1229
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
804
1230
|
const previousSize = current.track?.getSettings();
|
|
805
1231
|
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
806
1232
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -942,7 +1368,7 @@ class StreamClient {
|
|
|
942
1368
|
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
943
1369
|
}
|
|
944
1370
|
catch {
|
|
945
|
-
throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
1371
|
+
throw new errors_js_1.RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
946
1372
|
}
|
|
947
1373
|
}
|
|
948
1374
|
this.receiveQualities.set(producerId, quality);
|
|
@@ -993,7 +1419,7 @@ class StreamClient {
|
|
|
993
1419
|
*/
|
|
994
1420
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
995
1421
|
if (generation !== this.publishGeneration)
|
|
996
|
-
throw
|
|
1422
|
+
throw unpublishedWhile('camera');
|
|
997
1423
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
998
1424
|
// off. Setting the track to the camera's state first makes the producer
|
|
999
1425
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -1004,8 +1430,9 @@ class StreamClient {
|
|
|
1004
1430
|
// the device, so the browser sends every layer asked for (see
|
|
1005
1431
|
// videoEncodings).
|
|
1006
1432
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1007
|
-
|
|
1433
|
+
// The frame rate first: the size the project allows depends on it.
|
|
1008
1434
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1435
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1009
1436
|
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1010
1437
|
let codecs;
|
|
1011
1438
|
try {
|
|
@@ -1055,7 +1482,7 @@ class StreamClient {
|
|
|
1055
1482
|
if (generation !== this.publishGeneration) {
|
|
1056
1483
|
producer.close();
|
|
1057
1484
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1058
|
-
throw
|
|
1485
|
+
throw unpublishedWhile(what);
|
|
1059
1486
|
}
|
|
1060
1487
|
this.producers.set(producer.id, producer);
|
|
1061
1488
|
}
|
|
@@ -1094,7 +1521,7 @@ class StreamClient {
|
|
|
1094
1521
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1095
1522
|
reportCodecFallback(message) {
|
|
1096
1523
|
if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
|
|
1097
|
-
this.emit('error', new
|
|
1524
|
+
this.emit('error', new errors_js_1.RpcError('codec-fallback', message));
|
|
1098
1525
|
}
|
|
1099
1526
|
else if (!this.codecFallbackLogged) {
|
|
1100
1527
|
this.codecFallbackLogged = true;
|
|
@@ -1152,7 +1579,7 @@ class StreamClient {
|
|
|
1152
1579
|
const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
|
|
1153
1580
|
if (!live) {
|
|
1154
1581
|
if (!this.publishConstraints) {
|
|
1155
|
-
this.emit('error', new
|
|
1582
|
+
this.emit('error', new errors_js_1.RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1156
1583
|
return;
|
|
1157
1584
|
}
|
|
1158
1585
|
const ended = stream;
|
|
@@ -1174,20 +1601,22 @@ class StreamClient {
|
|
|
1174
1601
|
}
|
|
1175
1602
|
if (!stream)
|
|
1176
1603
|
return;
|
|
1177
|
-
await this.
|
|
1604
|
+
await this.publishStream(stream);
|
|
1178
1605
|
}
|
|
1179
1606
|
// ------------------------------------------------------------- screen sharing
|
|
1180
1607
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1181
1608
|
async publishScreen(options = {}) {
|
|
1182
1609
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1183
|
-
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1610
|
+
throw new errors_js_1.RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1611
|
+
if (!this.mediaPermissions.video)
|
|
1612
|
+
throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
|
|
1184
1613
|
if (this.screenStream || this.screenPending)
|
|
1185
|
-
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1614
|
+
throw new errors_js_1.RpcError('screen-share-active', 'a screen share is already active');
|
|
1186
1615
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
1187
1616
|
throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1188
1617
|
const mode = options.mode ?? 'detail';
|
|
1189
1618
|
if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
|
|
1190
|
-
throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1619
|
+
throw new errors_js_1.RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1191
1620
|
}
|
|
1192
1621
|
const generation = this.screenGeneration;
|
|
1193
1622
|
this.screenPending = true;
|
|
@@ -1199,9 +1628,9 @@ class StreamClient {
|
|
|
1199
1628
|
});
|
|
1200
1629
|
const video = stream.getVideoTracks()[0];
|
|
1201
1630
|
if (!video || video.readyState !== 'live')
|
|
1202
|
-
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1631
|
+
throw new errors_js_1.RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1203
1632
|
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1204
|
-
throw
|
|
1633
|
+
throw unpublishedWhile('screen');
|
|
1205
1634
|
this.screenStream = stream;
|
|
1206
1635
|
this.screenMode = mode;
|
|
1207
1636
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1211,7 +1640,7 @@ class StreamClient {
|
|
|
1211
1640
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1212
1641
|
await this.restoreScreen();
|
|
1213
1642
|
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1214
|
-
throw
|
|
1643
|
+
throw unpublishedWhile('screen');
|
|
1215
1644
|
this.screenStarted = true;
|
|
1216
1645
|
this.wasPublishing = true;
|
|
1217
1646
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1275,7 +1704,7 @@ class StreamClient {
|
|
|
1275
1704
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1276
1705
|
const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
|
|
1277
1706
|
// A project's explicit safety restriction also applies to screen video.
|
|
1278
|
-
const cap = (0, quality_js_1.topLayerSize)(width, height, this.
|
|
1707
|
+
const cap = (0, quality_js_1.topLayerSize)(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1279
1708
|
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1280
1709
|
if (cap.scale > screenScale)
|
|
1281
1710
|
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
@@ -1283,7 +1712,7 @@ class StreamClient {
|
|
|
1283
1712
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1284
1713
|
producer.close();
|
|
1285
1714
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1286
|
-
throw
|
|
1715
|
+
throw unpublishedWhile('screen');
|
|
1287
1716
|
}
|
|
1288
1717
|
this.producers.set(producer.id, producer);
|
|
1289
1718
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1294,7 +1723,12 @@ class StreamClient {
|
|
|
1294
1723
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1295
1724
|
appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
|
|
1296
1725
|
}));
|
|
1297
|
-
|
|
1726
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1727
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
1728
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
1729
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
1730
|
+
audio = undefined;
|
|
1731
|
+
}
|
|
1298
1732
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1299
1733
|
await keep(await transport.produce({
|
|
1300
1734
|
track: audio, stopTracks: false,
|
|
@@ -1302,19 +1736,34 @@ class StreamClient {
|
|
|
1302
1736
|
appData: { source: 'screen-audio' },
|
|
1303
1737
|
}));
|
|
1304
1738
|
}
|
|
1739
|
+
/**
|
|
1740
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
1741
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
1742
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
1743
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
1744
|
+
* the camera or device cannot provide it.
|
|
1745
|
+
*/
|
|
1305
1746
|
frameRateFor(track) {
|
|
1306
1747
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1307
1748
|
return 30;
|
|
1749
|
+
if (!this.allows60()) {
|
|
1750
|
+
this.frameRateFallback = true;
|
|
1751
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1752
|
+
return 30;
|
|
1753
|
+
}
|
|
1308
1754
|
const captured = track.getSettings().frameRate;
|
|
1309
|
-
if (
|
|
1755
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1310
1756
|
this.frameRateFallback = true;
|
|
1311
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
1757
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1312
1758
|
return 30;
|
|
1313
1759
|
}
|
|
1314
1760
|
return 60;
|
|
1315
1761
|
}
|
|
1316
|
-
async sampleCamera(baseline) {
|
|
1762
|
+
async sampleCamera(baseline, isCurrent) {
|
|
1763
|
+
const sampledProducer = this.cameraProducer();
|
|
1317
1764
|
const stats = await this.collectVideoStats(baseline);
|
|
1765
|
+
if (!isCurrent() || sampledProducer !== this.cameraProducer())
|
|
1766
|
+
return null;
|
|
1318
1767
|
if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
|
|
1319
1768
|
return stats;
|
|
1320
1769
|
if (this.cameraProducer()?.paused)
|
|
@@ -1328,14 +1777,14 @@ class StreamClient {
|
|
|
1328
1777
|
const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
|
|
1329
1778
|
this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
|
|
1330
1779
|
if (this.frameRateLowSamples >= 3) {
|
|
1331
|
-
this.frameRateFallback = true;
|
|
1332
1780
|
void this.cameraTask(async () => {
|
|
1333
1781
|
const producer = this.cameraProducer();
|
|
1334
|
-
if (!producer?.track || producer.closed)
|
|
1782
|
+
if (!isCurrent() || producer !== sampledProducer || this.frameRateFallback || !producer?.track || producer.closed)
|
|
1335
1783
|
return;
|
|
1336
|
-
|
|
1784
|
+
this.frameRateFallback = true;
|
|
1785
|
+
await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1337
1786
|
await this.reproduceCamera(producer, producer.track);
|
|
1338
|
-
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1787
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1339
1788
|
}).catch((error) => this.emit('error', error));
|
|
1340
1789
|
}
|
|
1341
1790
|
return stats;
|
|
@@ -1352,6 +1801,8 @@ class StreamClient {
|
|
|
1352
1801
|
* the user has not interacted with.
|
|
1353
1802
|
*/
|
|
1354
1803
|
async publishMusic(input, options) {
|
|
1804
|
+
if (!this.mediaPermissions.audio)
|
|
1805
|
+
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
1355
1806
|
const generation = this.publishGeneration;
|
|
1356
1807
|
if (this.music)
|
|
1357
1808
|
await this.stopMusic();
|
|
@@ -1362,7 +1813,7 @@ class StreamClient {
|
|
|
1362
1813
|
// already closed it, and the music must not be remembered for a
|
|
1363
1814
|
// reconnect to publish again.
|
|
1364
1815
|
if (generation !== this.publishGeneration)
|
|
1365
|
-
throw
|
|
1816
|
+
throw unpublishedWhile('music');
|
|
1366
1817
|
}
|
|
1367
1818
|
catch (err) {
|
|
1368
1819
|
music.close();
|
|
@@ -1375,7 +1826,7 @@ class StreamClient {
|
|
|
1375
1826
|
async produceMusic(music, generation) {
|
|
1376
1827
|
const transport = await this.ensureSendTransport();
|
|
1377
1828
|
if (generation !== this.publishGeneration)
|
|
1378
|
-
throw
|
|
1829
|
+
throw unpublishedWhile('music');
|
|
1379
1830
|
const producer = await transport.produce({
|
|
1380
1831
|
track: music.track,
|
|
1381
1832
|
stopTracks: false,
|
|
@@ -1435,13 +1886,49 @@ class StreamClient {
|
|
|
1435
1886
|
}
|
|
1436
1887
|
async consume(remote) {
|
|
1437
1888
|
if (!this.device)
|
|
1438
|
-
throw new
|
|
1889
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1439
1890
|
if (this.consumers.has(remote.producerId))
|
|
1440
1891
|
return;
|
|
1441
1892
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1442
1893
|
// consumer and shows you a delayed copy of yourself.
|
|
1443
1894
|
if (remote.identity === this.joinResult?.identity)
|
|
1444
1895
|
return;
|
|
1896
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
1897
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
1898
|
+
// asking for a refusal, and say so once per video.
|
|
1899
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
1900
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1901
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
1902
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
1903
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
1904
|
+
this.emit('error', new errors_js_1.RpcError('video-subscription-limit', `already receiving ${limit} videos, the most this project allows; ${remote.identity}'s video waits for one to end`));
|
|
1905
|
+
}
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
this.videoSlots.add(remote.producerId);
|
|
1909
|
+
}
|
|
1910
|
+
try {
|
|
1911
|
+
await this.consumeNow(remote);
|
|
1912
|
+
}
|
|
1913
|
+
catch (err) {
|
|
1914
|
+
if (!this.consumers.has(remote.producerId))
|
|
1915
|
+
this.videoSlots.delete(remote.producerId);
|
|
1916
|
+
throw err;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1920
|
+
async consumeSkipped() {
|
|
1921
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
1922
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1923
|
+
if (limit !== null && this.videoSlots.size >= limit)
|
|
1924
|
+
return;
|
|
1925
|
+
this.skippedVideos.delete(producerId);
|
|
1926
|
+
await this.consume(remote);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
async consumeNow(remote) {
|
|
1930
|
+
if (!this.device)
|
|
1931
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1445
1932
|
const transport = await this.ensureRecvTransport();
|
|
1446
1933
|
const params = (await this.rpc('consume', {
|
|
1447
1934
|
transportId: transport.id,
|
|
@@ -1462,7 +1949,7 @@ class StreamClient {
|
|
|
1462
1949
|
}
|
|
1463
1950
|
catch {
|
|
1464
1951
|
layers = undefined;
|
|
1465
|
-
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1952
|
+
this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1466
1953
|
}
|
|
1467
1954
|
}
|
|
1468
1955
|
if (layers) {
|
|
@@ -1474,6 +1961,7 @@ class StreamClient {
|
|
|
1474
1961
|
const kind = consumer.kind;
|
|
1475
1962
|
const source = remote.source ?? defaultSource(kind);
|
|
1476
1963
|
this.remoteSources.set(remote.producerId, source);
|
|
1964
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1477
1965
|
this.emit('track', {
|
|
1478
1966
|
identity: remote.identity,
|
|
1479
1967
|
kind,
|
|
@@ -1618,6 +2106,57 @@ class StreamClient {
|
|
|
1618
2106
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
1619
2107
|
endRoom() { return this.rpc('endRoom'); }
|
|
1620
2108
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2109
|
+
// ------------------------------------------------------- slots and metadata
|
|
2110
|
+
//
|
|
2111
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2112
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2113
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2114
|
+
// seed a board on join and follow claimChanged.
|
|
2115
|
+
/**
|
|
2116
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2117
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2118
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2119
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2120
|
+
*/
|
|
2121
|
+
async claim(key, identity) {
|
|
2122
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2123
|
+
}
|
|
2124
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2125
|
+
async release(key) {
|
|
2126
|
+
await this.rpc('release', { key });
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2130
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2131
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2132
|
+
*/
|
|
2133
|
+
async blockSlot(key) {
|
|
2134
|
+
await this.rpc('blockSlot', { key });
|
|
2135
|
+
}
|
|
2136
|
+
/** Unlock a slot. Moderators only. */
|
|
2137
|
+
async unblockSlot(key) {
|
|
2138
|
+
await this.rpc('unblockSlot', { key });
|
|
2139
|
+
}
|
|
2140
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2141
|
+
async setMetadata(metadata) {
|
|
2142
|
+
await this.rpc('setMetadata', { metadata });
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2146
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2147
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2148
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2149
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2150
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2151
|
+
*/
|
|
2152
|
+
async setRoomMetadata(metadata, options = {}) {
|
|
2153
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2154
|
+
metadata,
|
|
2155
|
+
...(options.replace ? { replace: true } : {}),
|
|
2156
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2157
|
+
}));
|
|
2158
|
+
return res?.metadata ?? null;
|
|
2159
|
+
}
|
|
1621
2160
|
// ------------------------------------------------------------------- chat
|
|
1622
2161
|
//
|
|
1623
2162
|
// The room's chat rides this same socket. Someone on a chat-only socket
|