@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/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". */
|
|
@@ -77,10 +86,32 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
|
77
86
|
* otherwise starts low and takes many seconds to climb to the top layer.
|
|
78
87
|
*/
|
|
79
88
|
const VIDEO_START_BITRATE_KBPS = 2000;
|
|
89
|
+
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
90
|
+
const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
|
|
80
91
|
/** 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
|
|
92
|
+
const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
93
|
+
/**
|
|
94
|
+
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
95
|
+
* SDP only, so the server would never see it. Declare it in the producer's
|
|
96
|
+
* parameters too: the media node bills HD on a declared value above 64,000
|
|
97
|
+
* (spec §4) and refuses one in a project limited to standard audio, so a
|
|
98
|
+
* standard microphone declares 64,000 and an HD one 128,000. Music and screen
|
|
99
|
+
* audio go out as they always did.
|
|
100
|
+
*/
|
|
101
|
+
function declareAudioQuality(rtpParameters, appData) {
|
|
102
|
+
const quality = appData.audioQuality;
|
|
103
|
+
if (appData.source !== 'mic' || (quality !== 'std' && quality !== 'hd'))
|
|
104
|
+
return rtpParameters;
|
|
105
|
+
const maxaveragebitrate = media_permissions_js_1.AUDIO_SETTINGS[quality].maxBitrate;
|
|
106
|
+
return {
|
|
107
|
+
...rtpParameters,
|
|
108
|
+
codecs: rtpParameters.codecs.map((codec) => (codec.mimeType.toLowerCase() === 'audio/opus'
|
|
109
|
+
? { ...codec, parameters: { ...codec.parameters, maxaveragebitrate } } : codec)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
82
112
|
class StreamClient {
|
|
83
113
|
wsUrl;
|
|
114
|
+
options;
|
|
84
115
|
ws = null;
|
|
85
116
|
device = null;
|
|
86
117
|
sendTransport = null;
|
|
@@ -132,7 +163,15 @@ class StreamClient {
|
|
|
132
163
|
* the full picture.
|
|
133
164
|
*/
|
|
134
165
|
preferredLayers = new Map();
|
|
166
|
+
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
167
|
+
endRoomWaiters = new Set();
|
|
135
168
|
closedByUs = false;
|
|
169
|
+
/**
|
|
170
|
+
* Bumped when the session ends for good: close(), or finish(). A connect()
|
|
171
|
+
* under way when it did -- the app's, or a reconnect's -- sees the change
|
|
172
|
+
* after its next await and stops, so a closed session never comes back.
|
|
173
|
+
*/
|
|
174
|
+
lifecycle = 0;
|
|
136
175
|
reconnecting = false;
|
|
137
176
|
reconnectAttempt = 0;
|
|
138
177
|
/** Set once a connect() has joined and set up. Only such a session is reconnected. */
|
|
@@ -151,9 +190,30 @@ class StreamClient {
|
|
|
151
190
|
sendStatsBaseline = new Map();
|
|
152
191
|
recvStatsBaseline = new Map();
|
|
153
192
|
limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => this.emit('videoLimited', event), undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
154
|
-
|
|
193
|
+
/** Where the session stands; see connectionStateChanged. */
|
|
194
|
+
state = 'idle';
|
|
195
|
+
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
196
|
+
tokenExpiry;
|
|
197
|
+
tokenTimer = null;
|
|
198
|
+
/** Whose each received track is and whether it is switched off, by producerId. */
|
|
199
|
+
remoteInfo = new Map();
|
|
200
|
+
/** Videos left unconsumed at the fan-out limit, oldest first; consumed as others end. */
|
|
201
|
+
skippedVideos = new Map();
|
|
202
|
+
/** Producers whose video this client receives, or is about to: the fan-out count. */
|
|
203
|
+
videoSlots = new Set();
|
|
204
|
+
/** The quality the microphone producer was made with. */
|
|
205
|
+
micQuality = 'std';
|
|
206
|
+
// Changes to the microphone producer run one at a time, as the camera's do.
|
|
207
|
+
micQueue = Promise.resolve();
|
|
208
|
+
// A request the project does not allow is reported once, until it is made again.
|
|
209
|
+
audioCapReported = false;
|
|
210
|
+
sizeCapReported = false;
|
|
211
|
+
networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
212
|
+
constructor(wsUrl, token, options = {}) {
|
|
155
213
|
this.wsUrl = wsUrl;
|
|
214
|
+
this.options = options;
|
|
156
215
|
this.token = token;
|
|
216
|
+
this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
|
|
157
217
|
}
|
|
158
218
|
on(event, fn) {
|
|
159
219
|
let set = this.listeners.get(event);
|
|
@@ -162,15 +222,46 @@ class StreamClient {
|
|
|
162
222
|
this.listeners.set(event, set);
|
|
163
223
|
}
|
|
164
224
|
set.add(fn);
|
|
165
|
-
// videoLimited
|
|
225
|
+
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
226
|
+
// they run only while heard.
|
|
166
227
|
if (event === 'videoLimited')
|
|
167
228
|
this.syncLimitWatch();
|
|
229
|
+
if (event === 'networkQuality')
|
|
230
|
+
this.syncNetworkWatch();
|
|
168
231
|
return () => {
|
|
169
232
|
set.delete(fn);
|
|
170
233
|
if (event === 'videoLimited')
|
|
171
234
|
this.syncLimitWatch();
|
|
235
|
+
if (event === 'networkQuality')
|
|
236
|
+
this.syncNetworkWatch();
|
|
172
237
|
};
|
|
173
238
|
}
|
|
239
|
+
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
240
|
+
get connectionState() {
|
|
241
|
+
return this.state;
|
|
242
|
+
}
|
|
243
|
+
setState(next) {
|
|
244
|
+
if (next === this.state)
|
|
245
|
+
return;
|
|
246
|
+
const previous = this.state;
|
|
247
|
+
this.state = next;
|
|
248
|
+
if (next === 'closed')
|
|
249
|
+
this.stopTimers();
|
|
250
|
+
this.emit('connectionStateChanged', { state: next, previous });
|
|
251
|
+
}
|
|
252
|
+
/** Over for good: the state, then the event that says why. */
|
|
253
|
+
finish(reason) {
|
|
254
|
+
this.closedByUs = true;
|
|
255
|
+
this.lifecycle += 1;
|
|
256
|
+
this.setState('closed');
|
|
257
|
+
this.emit('closed', reason);
|
|
258
|
+
}
|
|
259
|
+
stopTimers() {
|
|
260
|
+
if (this.tokenTimer)
|
|
261
|
+
clearTimeout(this.tokenTimer);
|
|
262
|
+
this.tokenTimer = null;
|
|
263
|
+
this.networkWatch.set(false);
|
|
264
|
+
}
|
|
174
265
|
listenerCount(event) {
|
|
175
266
|
return this.listeners.get(event)?.size ?? 0;
|
|
176
267
|
}
|
|
@@ -186,20 +277,31 @@ class StreamClient {
|
|
|
186
277
|
}
|
|
187
278
|
// ------------------------------------------------------------- connection
|
|
188
279
|
async connect() {
|
|
280
|
+
const lifecycle = this.lifecycle;
|
|
281
|
+
const closed = () => lifecycle !== this.lifecycle;
|
|
189
282
|
let result;
|
|
283
|
+
if (this.state === 'idle' || this.state === 'closed')
|
|
284
|
+
this.setState('connecting');
|
|
190
285
|
try {
|
|
191
286
|
await this.openSocket();
|
|
192
287
|
result = (await this.rpc('join', { token: this.token }));
|
|
193
288
|
this.joinResult = result;
|
|
194
289
|
this.device = this.createDevice();
|
|
195
290
|
await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
|
|
291
|
+
// close() ran while the Device loaded: going on would set the state
|
|
292
|
+
// back to connected, and emit joined, after closed.
|
|
293
|
+
if (closed())
|
|
294
|
+
throw closedWhileConnecting();
|
|
196
295
|
}
|
|
197
296
|
catch (err) {
|
|
198
297
|
// A session that never got going: the app has the error. Left open,
|
|
199
298
|
// the socket would be closed by the server's join deadline instead, and
|
|
200
299
|
// that close would start reconnecting a session that never began.
|
|
201
|
-
if (!this.joinedOnce)
|
|
300
|
+
if (!this.joinedOnce) {
|
|
202
301
|
this.detachSocket();
|
|
302
|
+
if (this.state === 'connecting')
|
|
303
|
+
this.setState('idle');
|
|
304
|
+
}
|
|
203
305
|
throw err;
|
|
204
306
|
}
|
|
205
307
|
// Joined and set up: the next drop gets every reconnect attempt again.
|
|
@@ -208,10 +310,15 @@ class StreamClient {
|
|
|
208
310
|
// retried about twice a second, forever.
|
|
209
311
|
this.reconnectAttempt = 0;
|
|
210
312
|
this.joinedOnce = true;
|
|
313
|
+
this.skippedVideos.clear();
|
|
314
|
+
this.setState('connected');
|
|
315
|
+
this.scheduleTokenExpiring();
|
|
211
316
|
// A role or mute can change while the socket is away. The fresh roster
|
|
212
317
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
213
318
|
if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
|
|
214
319
|
await this.unpublish();
|
|
320
|
+
if (closed())
|
|
321
|
+
throw closedWhileConnecting();
|
|
215
322
|
}
|
|
216
323
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
217
324
|
if (own?.muted)
|
|
@@ -233,20 +340,33 @@ class StreamClient {
|
|
|
233
340
|
if (!present.has(producerId))
|
|
234
341
|
this.remoteVideoLayers.delete(producerId);
|
|
235
342
|
// Subscribe to everything already publishing before we announce readiness.
|
|
343
|
+
// A close() part-way stops it: what is left would only fail, after closed.
|
|
236
344
|
for (const p of result.producers) {
|
|
237
|
-
|
|
345
|
+
if (closed())
|
|
346
|
+
throw closedWhileConnecting();
|
|
347
|
+
await this.consume(p).catch((err) => { if (!closed())
|
|
348
|
+
this.emit('error', err); });
|
|
238
349
|
}
|
|
239
350
|
// Restore our own broadcast. This is what makes an origin failover
|
|
240
351
|
// survivable for the host: it reconnects onto the new origin and the
|
|
241
352
|
// stream comes back on its own.
|
|
242
353
|
if (this.wasPublishing && (result.role === 'host' || result.role === 'cohost')) {
|
|
243
|
-
await this.republish().catch((err) =>
|
|
354
|
+
await this.republish().catch((err) => { if (!closed())
|
|
355
|
+
this.emit('error', err); });
|
|
244
356
|
}
|
|
357
|
+
if (closed())
|
|
358
|
+
throw closedWhileConnecting();
|
|
359
|
+
this.syncNetworkWatch();
|
|
245
360
|
return result;
|
|
246
361
|
}
|
|
247
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* A new mediasoup Device for each join: the handler named in the options, or
|
|
364
|
+
* the one detected from the browser. Its own method so tests, which have no
|
|
365
|
+
* WebRTC, can stand one in.
|
|
366
|
+
*/
|
|
248
367
|
createDevice() {
|
|
249
|
-
|
|
368
|
+
const { handlerName } = this.options;
|
|
369
|
+
return handlerName ? new mediasoup_client_1.Device({ handlerName }) : new mediasoup_client_1.Device();
|
|
250
370
|
}
|
|
251
371
|
openSocket() {
|
|
252
372
|
// Abandon any previous socket first. Its close handler checks identity
|
|
@@ -254,15 +374,13 @@ class StreamClient {
|
|
|
254
374
|
// reconnect -- which is what let multiple reconnect chains pile up.
|
|
255
375
|
this.detachSocket();
|
|
256
376
|
return new Promise((resolve, reject) => {
|
|
257
|
-
const
|
|
258
|
-
url.searchParams.set('token', this.token);
|
|
259
|
-
const ws = new WebSocket(url.toString());
|
|
377
|
+
const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
|
|
260
378
|
this.ws = ws;
|
|
261
379
|
const onOpen = () => {
|
|
262
380
|
this.emit('connected');
|
|
263
381
|
resolve();
|
|
264
382
|
};
|
|
265
|
-
const onError = () => reject(new
|
|
383
|
+
const onError = () => reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
|
|
266
384
|
ws.addEventListener('open', onOpen, { once: true });
|
|
267
385
|
ws.addEventListener('error', onError, { once: true });
|
|
268
386
|
ws.addEventListener('message', (ev) => {
|
|
@@ -275,49 +393,59 @@ class StreamClient {
|
|
|
275
393
|
if (this.ws !== ws)
|
|
276
394
|
return;
|
|
277
395
|
this.ws = null;
|
|
278
|
-
this.failAllPending(new
|
|
396
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
|
|
279
397
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
280
398
|
if (this.closedByUs)
|
|
281
399
|
return;
|
|
282
400
|
if (ev.code === CLOSE_REPLACED) {
|
|
283
401
|
// Another session for this identity is live. Reconnecting would
|
|
284
402
|
// displace it, and it would displace us right back, forever.
|
|
285
|
-
this.emit('error', new
|
|
286
|
-
this.closedByUs = true;
|
|
403
|
+
this.emit('error', new errors_js_1.RpcError('replaced', 'this identity connected from somewhere else'));
|
|
287
404
|
// The producers outlive this close until the app calls close(); the
|
|
288
405
|
// videoLimited sampling must not, or it reports after `closed`.
|
|
289
406
|
this.limitWatch.reset();
|
|
290
|
-
this.
|
|
407
|
+
this.finish('replaced');
|
|
291
408
|
return;
|
|
292
409
|
}
|
|
293
410
|
if (ev.code === CLOSE_ENDED) {
|
|
294
411
|
// Normally roomClosed has already ended the session; this covers a
|
|
295
412
|
// close that arrived without it.
|
|
296
|
-
this.closedByUs = true;
|
|
297
413
|
this.limitWatch.reset();
|
|
298
|
-
this.
|
|
414
|
+
this.finish('room-closed');
|
|
299
415
|
return;
|
|
300
416
|
}
|
|
301
417
|
// Before a first successful join the app is still waiting on
|
|
302
418
|
// connect(), which reports the failure; there is nothing to restore.
|
|
303
419
|
if (!this.joinedOnce)
|
|
304
420
|
return;
|
|
421
|
+
this.setState('reconnecting');
|
|
422
|
+
this.networkWatch.set(false);
|
|
305
423
|
void this.scheduleReconnect();
|
|
306
424
|
});
|
|
307
425
|
});
|
|
308
426
|
}
|
|
309
|
-
detachSocket() {
|
|
427
|
+
detachSocket(reason = 'superseded') {
|
|
310
428
|
const ws = this.ws;
|
|
311
429
|
this.ws = null;
|
|
312
430
|
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
313
431
|
try {
|
|
314
|
-
ws.close(1000,
|
|
432
|
+
ws.close(1000, reason);
|
|
315
433
|
}
|
|
316
434
|
catch {
|
|
317
435
|
/* already closing */
|
|
318
436
|
}
|
|
319
437
|
}
|
|
320
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* A reconnect that ends the session lets go of its socket. A refused join
|
|
441
|
+
* leaves that socket open (connect() keeps it once a session has joined),
|
|
442
|
+
* and the session would otherwise report closed with it still attached and
|
|
443
|
+
* still sending.
|
|
444
|
+
*/
|
|
445
|
+
releaseSocket() {
|
|
446
|
+
this.detachSocket('gave up');
|
|
447
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session has ended'));
|
|
448
|
+
}
|
|
321
449
|
/**
|
|
322
450
|
* Reconnection is mandatory, not optional: nodes get drained, relays die and
|
|
323
451
|
* promotions move you to a different node. Always re-ask the orchestrator
|
|
@@ -334,9 +462,9 @@ class StreamClient {
|
|
|
334
462
|
while (!this.closedByUs) {
|
|
335
463
|
this.reconnectAttempt += 1;
|
|
336
464
|
if (this.reconnectAttempt > MAX_RECONNECT_ATTEMPTS) {
|
|
337
|
-
this.emit('error', new
|
|
338
|
-
this.
|
|
339
|
-
this.
|
|
465
|
+
this.emit('error', new errors_js_1.RpcError('reconnect-gave-up', `gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`));
|
|
466
|
+
this.releaseSocket();
|
|
467
|
+
this.finish('gave-up');
|
|
340
468
|
return;
|
|
341
469
|
}
|
|
342
470
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
@@ -354,13 +482,17 @@ class StreamClient {
|
|
|
354
482
|
return;
|
|
355
483
|
}
|
|
356
484
|
catch (err) {
|
|
485
|
+
// Ended while this attempt was under way -- by close(), or by the
|
|
486
|
+
// server -- and that has been reported: nothing follows closed.
|
|
487
|
+
if (this.closedByUs)
|
|
488
|
+
return;
|
|
357
489
|
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.
|
|
490
|
+
// Stop on failures a retry cannot resolve (the registry's terminal
|
|
491
|
+
// codes). Hammering a room that has ended produces one error per
|
|
492
|
+
// attempt forever and drowns out whatever actually went wrong.
|
|
493
|
+
if (err instanceof errors_js_1.RpcError && (0, errors_js_1.isTerminalError)(err.code)) {
|
|
494
|
+
this.releaseSocket();
|
|
495
|
+
this.finish('gave-up');
|
|
364
496
|
return;
|
|
365
497
|
}
|
|
366
498
|
}
|
|
@@ -376,6 +508,7 @@ class StreamClient {
|
|
|
376
508
|
// others have already reported themselves and set closedByUs.
|
|
377
509
|
const wasOpen = !this.closedByUs;
|
|
378
510
|
this.closedByUs = true;
|
|
511
|
+
this.lifecycle += 1;
|
|
379
512
|
this.publishGeneration += 1;
|
|
380
513
|
this.wasPublishing = false;
|
|
381
514
|
this.teardownMedia();
|
|
@@ -387,6 +520,7 @@ class StreamClient {
|
|
|
387
520
|
this.music = null;
|
|
388
521
|
// Nothing is emitted after closing, so not even a clearing videoLimited.
|
|
389
522
|
this.limitWatch.reset();
|
|
523
|
+
this.skippedVideos.clear();
|
|
390
524
|
// Producers no longer stop their tracks, so releasing the camera and
|
|
391
525
|
// microphone is ours to do -- otherwise the device light stays on.
|
|
392
526
|
this.localStream?.getTracks().forEach((t) => t.stop());
|
|
@@ -394,6 +528,11 @@ class StreamClient {
|
|
|
394
528
|
const ws = this.ws;
|
|
395
529
|
this.ws = null;
|
|
396
530
|
ws?.close(1000, 'client closed');
|
|
531
|
+
// Detached first, so the socket's own close handler no longer fails what
|
|
532
|
+
// was waiting on it: done here, or a join or a request in flight would
|
|
533
|
+
// wait out its 15 s timeout.
|
|
534
|
+
this.failAllPending(new errors_js_1.RpcError('not-connected', 'the session was closed'));
|
|
535
|
+
this.setState('closed');
|
|
397
536
|
if (wasOpen)
|
|
398
537
|
this.emit('closed', 'left');
|
|
399
538
|
}
|
|
@@ -401,6 +540,9 @@ class StreamClient {
|
|
|
401
540
|
for (const c of this.consumers.values())
|
|
402
541
|
c.close();
|
|
403
542
|
this.consumers.clear();
|
|
543
|
+
this.remoteInfo.clear();
|
|
544
|
+
this.skippedVideos.clear();
|
|
545
|
+
this.videoSlots.clear();
|
|
404
546
|
for (const p of this.producers.values())
|
|
405
547
|
p.close();
|
|
406
548
|
this.producers.clear();
|
|
@@ -417,22 +559,137 @@ class StreamClient {
|
|
|
417
559
|
this.sendTransportPending = null;
|
|
418
560
|
this.recvTransportPending = null;
|
|
419
561
|
this.device = null;
|
|
562
|
+
this.syncNetworkWatch();
|
|
420
563
|
}
|
|
421
|
-
/**
|
|
564
|
+
/**
|
|
565
|
+
* Everything currently known about the room, for rendering: the join
|
|
566
|
+
* result, kept current -- participants, roles, hands, mutes, display
|
|
567
|
+
* metadata, slots and room metadata follow the events.
|
|
568
|
+
*/
|
|
422
569
|
get info() {
|
|
423
570
|
return this.joinResult;
|
|
424
571
|
}
|
|
572
|
+
/** Who is in the room now, this client included; kept current from the roster events. */
|
|
573
|
+
get participants() {
|
|
574
|
+
return [...(this.joinResult?.participants ?? [])];
|
|
575
|
+
}
|
|
576
|
+
/** Every track this client is receiving now. */
|
|
577
|
+
get remoteTracks() {
|
|
578
|
+
const out = [];
|
|
579
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
580
|
+
const info = this.remoteInfo.get(producerId);
|
|
581
|
+
if (!info || consumer.closed)
|
|
582
|
+
continue;
|
|
583
|
+
out.push({
|
|
584
|
+
producerId, identity: info.identity, kind: info.kind, track: consumer.track,
|
|
585
|
+
source: this.remoteSources.get(producerId) ?? defaultSource(info.kind), paused: info.paused,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* What this project lets this client publish: from the join result's
|
|
592
|
+
* `media` (servers with wallet billing), else its `video` policy, else
|
|
593
|
+
* everything before a join. Offer only these in your UI; the SDK refuses
|
|
594
|
+
* or caps anything else with the server's own codes.
|
|
595
|
+
*/
|
|
596
|
+
get mediaPermissions() {
|
|
597
|
+
return (0, media_permissions_js_1.resolvePermissions)(this.joinResult);
|
|
598
|
+
}
|
|
599
|
+
/** How this session is billed: `legacy` (minutes) or `wallet`; null before a join or on older servers. */
|
|
600
|
+
get billingMode() {
|
|
601
|
+
return this.joinResult?.billing?.mode ?? null;
|
|
602
|
+
}
|
|
603
|
+
// ------------------------------------------------------------ token refresh
|
|
604
|
+
/**
|
|
605
|
+
* Hand the session a newer room token, minted by your backend for the same
|
|
606
|
+
* room and identity (`POST /v1/rooms/{roomId}/tokens`), typically on
|
|
607
|
+
* `tokenExpiring`. On a live connection the server checks it like a join
|
|
608
|
+
* token (`bad-token`, or `token-mismatch` for another room or identity; the
|
|
609
|
+
* session then keeps its token); otherwise it is kept for the next join,
|
|
610
|
+
* which checks it there. Resolves to when it expires (unix ms), when known.
|
|
611
|
+
*/
|
|
612
|
+
async updateToken(token) {
|
|
613
|
+
if (typeof token !== 'string' || token.trim() === '')
|
|
614
|
+
throw new TypeError('updateToken needs a token');
|
|
615
|
+
if (this.state === 'closed')
|
|
616
|
+
throw new errors_js_1.RpcError('not-connected', 'this session is closed; create a new client');
|
|
617
|
+
if (this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
|
|
618
|
+
try {
|
|
619
|
+
const res = (await this.rpc('refreshToken', { token }));
|
|
620
|
+
const at = typeof res?.expiresAt === 'number' && res.expiresAt > 0 ? res.expiresAt * 1000 : null;
|
|
621
|
+
this.setToken(token, at);
|
|
622
|
+
return { expiresAt: this.tokenExpiry };
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
// An older server has no refreshToken, and a socket that dropped
|
|
626
|
+
// during the call is reconnecting: either way keep the token for the
|
|
627
|
+
// next join, which checks it.
|
|
628
|
+
if (!(err instanceof errors_js_1.RpcError) || (err.code !== 'unknown-method' && err.code !== 'not-connected'))
|
|
629
|
+
throw err;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
this.setToken(token, null);
|
|
633
|
+
return { expiresAt: this.tokenExpiry };
|
|
634
|
+
}
|
|
635
|
+
/** The token reconnects use from now on, with its expiry (read from the token when not given). */
|
|
636
|
+
setToken(token, expiresAt) {
|
|
637
|
+
this.token = token;
|
|
638
|
+
this.tokenExpiry = expiresAt ?? (0, token_js_1.tokenExpiresAt)(token);
|
|
639
|
+
this.scheduleTokenExpiring();
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Warn TOKEN_EXPIRING_LEAD_MS before the token held for reconnects
|
|
643
|
+
* expires. Every new token -- the server's refresh, a promotion's, the
|
|
644
|
+
* app's -- reschedules it, so it fires only when nobody refreshed it.
|
|
645
|
+
*/
|
|
646
|
+
scheduleTokenExpiring() {
|
|
647
|
+
if (this.tokenTimer)
|
|
648
|
+
clearTimeout(this.tokenTimer);
|
|
649
|
+
this.tokenTimer = null;
|
|
650
|
+
const expiresAt = this.tokenExpiry;
|
|
651
|
+
if (expiresAt === null || this.state === 'idle' || this.state === 'closed')
|
|
652
|
+
return;
|
|
653
|
+
// setTimeout cannot wait longer than about 24.8 days.
|
|
654
|
+
const delay = Math.min(Math.max(0, expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS - Date.now()), 2_147_000_000);
|
|
655
|
+
this.tokenTimer = setTimeout(() => {
|
|
656
|
+
this.tokenTimer = null;
|
|
657
|
+
if (this.state === 'closed' || this.tokenExpiry !== expiresAt)
|
|
658
|
+
return;
|
|
659
|
+
if (expiresAt - token_js_1.TOKEN_EXPIRING_LEAD_MS > Date.now()) {
|
|
660
|
+
this.scheduleTokenExpiring(); // woke early from a clamped wait
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
this.emit('tokenExpiring', { expiresAt, secondsLeft: Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) });
|
|
664
|
+
}, delay);
|
|
665
|
+
}
|
|
666
|
+
// ------------------------------------------------------------ network quality
|
|
667
|
+
/** networkQuality sampling runs while someone listens and the session is connected. */
|
|
668
|
+
syncNetworkWatch() {
|
|
669
|
+
this.networkWatch.set(!this.closedByUs && this.state === 'connected' && this.listenerCount('networkQuality') > 0);
|
|
670
|
+
}
|
|
671
|
+
/** Each live media transport's statistics, keyed by transport so a new one starts fresh. */
|
|
672
|
+
async transportStats() {
|
|
673
|
+
const out = new Map();
|
|
674
|
+
for (const transport of [this.sendTransport, this.recvTransport]) {
|
|
675
|
+
if (!transport || transport.closed)
|
|
676
|
+
continue;
|
|
677
|
+
const report = await transport.getStats();
|
|
678
|
+
out.set(transport.id, report.values());
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
425
682
|
// -------------------------------------------------------------------- rpc
|
|
426
683
|
rpc(method, params = {}) {
|
|
427
684
|
return new Promise((resolve, reject) => {
|
|
428
685
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
429
|
-
reject(new
|
|
686
|
+
reject(new errors_js_1.RpcError('not-connected', 'socket is not open'));
|
|
430
687
|
return;
|
|
431
688
|
}
|
|
432
689
|
const id = this.nextId++;
|
|
433
690
|
const timer = setTimeout(() => {
|
|
434
691
|
this.pending.delete(id);
|
|
435
|
-
reject(new
|
|
692
|
+
reject(new errors_js_1.RpcError('timeout', `${method} timed out`));
|
|
436
693
|
}, RPC_TIMEOUT_MS);
|
|
437
694
|
this.pending.set(id, { resolve, reject, timer });
|
|
438
695
|
this.ws.send(JSON.stringify({ id, method, params }));
|
|
@@ -453,6 +710,10 @@ class StreamClient {
|
|
|
453
710
|
catch {
|
|
454
711
|
return;
|
|
455
712
|
}
|
|
713
|
+
// Valid JSON that is not a message object ("null" would throw below) is
|
|
714
|
+
// ignored like any other frame that is not a message.
|
|
715
|
+
if (typeof msg !== 'object' || msg === null)
|
|
716
|
+
return;
|
|
456
717
|
if (typeof msg.id === 'number') {
|
|
457
718
|
const p = this.pending.get(msg.id);
|
|
458
719
|
if (!p)
|
|
@@ -463,7 +724,7 @@ class StreamClient {
|
|
|
463
724
|
p.resolve(msg.result);
|
|
464
725
|
else {
|
|
465
726
|
const e = msg.error;
|
|
466
|
-
p.reject(new RpcError(e?.code ?? 'error', e?.message ?? 'unknown'));
|
|
727
|
+
p.reject(new errors_js_1.RpcError(e?.code ?? 'error', e?.message ?? 'unknown', e?.data));
|
|
467
728
|
}
|
|
468
729
|
return;
|
|
469
730
|
}
|
|
@@ -481,17 +742,27 @@ class StreamClient {
|
|
|
481
742
|
this.preferredLayers.delete(producerId);
|
|
482
743
|
this.receiveQualities.delete(producerId);
|
|
483
744
|
this.remoteVideoLayers.delete(producerId);
|
|
745
|
+
this.skippedVideos.delete(producerId);
|
|
484
746
|
const consumer = this.consumers.get(producerId);
|
|
485
747
|
if (!consumer)
|
|
486
748
|
break; // never consumed it, or already cleaned up
|
|
749
|
+
this.videoSlots.delete(producerId);
|
|
487
750
|
consumer.close();
|
|
488
751
|
this.consumers.delete(producerId);
|
|
489
752
|
this.remoteSources.delete(producerId);
|
|
753
|
+
this.remoteInfo.delete(producerId);
|
|
490
754
|
this.emit('trackEnded', { producerId, identity });
|
|
755
|
+
// A video slot opened under the fan-out limit: the oldest video left
|
|
756
|
+
// waiting takes it.
|
|
757
|
+
if (consumer.kind === 'video')
|
|
758
|
+
void this.consumeSkipped().catch((err) => this.emit('error', err));
|
|
491
759
|
break;
|
|
492
760
|
}
|
|
493
761
|
case 'producerPaused': {
|
|
494
762
|
const p = data;
|
|
763
|
+
const info = this.remoteInfo.get(p.producerId);
|
|
764
|
+
if (info)
|
|
765
|
+
info.paused = Boolean(p.paused);
|
|
495
766
|
this.emit('trackMuted', {
|
|
496
767
|
identity: p.identity,
|
|
497
768
|
kind: p.kind,
|
|
@@ -501,27 +772,40 @@ class StreamClient {
|
|
|
501
772
|
});
|
|
502
773
|
break;
|
|
503
774
|
}
|
|
504
|
-
case 'participantJoined':
|
|
505
|
-
|
|
775
|
+
case 'participantJoined': {
|
|
776
|
+
const p = data;
|
|
777
|
+
// A reconnecting participant joins again: replace, never duplicate.
|
|
778
|
+
this.updateRoster((list) => [...list.filter((q) => q.identity !== p.identity), p]);
|
|
779
|
+
this.emit('participantJoined', p);
|
|
506
780
|
break;
|
|
781
|
+
}
|
|
507
782
|
case 'participantMuted': {
|
|
508
783
|
const wire = data;
|
|
509
784
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
});
|
|
785
|
+
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
786
|
+
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
787
|
+
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
788
|
+
}));
|
|
515
789
|
this.emit('participantMuted', e);
|
|
516
790
|
break;
|
|
517
791
|
}
|
|
518
|
-
case '
|
|
519
|
-
|
|
792
|
+
case 'participantUpdated': {
|
|
793
|
+
const e = data;
|
|
794
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, metadata: e.metadata })));
|
|
795
|
+
this.emit('participantUpdated', { identity: e.identity, metadata: e.metadata ?? null });
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
case 'participantLeft': {
|
|
799
|
+
const e = data;
|
|
800
|
+
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
801
|
+
this.emit('participantLeft', e);
|
|
520
802
|
break;
|
|
803
|
+
}
|
|
521
804
|
case 'roleChanged': {
|
|
522
805
|
const e = data;
|
|
523
806
|
if (e.identity === this.joinResult?.identity)
|
|
524
807
|
this.joinResult.role = e.role;
|
|
808
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p : { ...p, role: e.role })));
|
|
525
809
|
this.emit('roleChanged', e);
|
|
526
810
|
// Demoted: the server has already closed our producers. Release the
|
|
527
811
|
// camera and microphone too, instead of capturing for nobody.
|
|
@@ -532,9 +816,13 @@ class StreamClient {
|
|
|
532
816
|
break;
|
|
533
817
|
}
|
|
534
818
|
case 'handRaised':
|
|
535
|
-
case 'handLowered':
|
|
819
|
+
case 'handLowered': {
|
|
820
|
+
const e = data;
|
|
821
|
+
this.updateRoster((list) => list.map((p) => (p.identity !== e.identity ? p
|
|
822
|
+
: { ...p, handRaised: Boolean(e.handRaised), ...(e.handRaised ? { handMetadata: e.metadata } : { handMetadata: undefined }) })));
|
|
536
823
|
this.emit('handRaised', data);
|
|
537
824
|
break;
|
|
825
|
+
}
|
|
538
826
|
case 'forceMuted': {
|
|
539
827
|
// The server has already paused that kind (an audio mute includes
|
|
540
828
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
@@ -547,20 +835,33 @@ class StreamClient {
|
|
|
547
835
|
this.emit('forceMuted', e);
|
|
548
836
|
break;
|
|
549
837
|
}
|
|
838
|
+
case 'claimChanged': {
|
|
839
|
+
const e = data;
|
|
840
|
+
this.applyClaim(e);
|
|
841
|
+
this.emit('claimChanged', { key: e.key, identity: e.identity || null, state: e.state, reason: e.reason ?? '' });
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
case 'roomMetadataChanged': {
|
|
845
|
+
const e = data;
|
|
846
|
+
if (this.joinResult)
|
|
847
|
+
this.joinResult.metadata = e.metadata;
|
|
848
|
+
this.emit('roomMetadataChanged', { metadata: e.metadata ?? null, ...(e.by ? { by: e.by } : {}) });
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
550
851
|
case 'tokenRefreshed': {
|
|
551
852
|
// A room outlives its join token many times over. The server hands us a
|
|
552
853
|
// fresh one periodically so a reconnect at any point still works --
|
|
553
854
|
// without this, any disconnect after the token's lifetime is terminal.
|
|
554
855
|
const e = data;
|
|
555
856
|
if (e.token)
|
|
556
|
-
this.token
|
|
857
|
+
this.setToken(e.token, typeof e.expiresAt === 'number' && e.expiresAt > 0 ? e.expiresAt * 1000 : null);
|
|
557
858
|
break;
|
|
558
859
|
}
|
|
559
860
|
case 'mustReconnect': {
|
|
560
861
|
const e = data;
|
|
561
862
|
// A promotion hands us a fresh token carrying the new role.
|
|
562
863
|
if (e.token)
|
|
563
|
-
this.
|
|
864
|
+
this.setToken(e.token, null);
|
|
564
865
|
this.emit('mustReconnect', e);
|
|
565
866
|
if (e.retry) {
|
|
566
867
|
this.teardownMedia();
|
|
@@ -576,16 +877,27 @@ class StreamClient {
|
|
|
576
877
|
case 'usageLimit':
|
|
577
878
|
this.emit('usageLimit', d);
|
|
578
879
|
break;
|
|
880
|
+
case 'walletLow': {
|
|
881
|
+
const e = data;
|
|
882
|
+
const remainingSeconds = typeof e?.remainingSeconds === 'number' && Number.isFinite(e.remainingSeconds) ? e.remainingSeconds : 0;
|
|
883
|
+
this.emit('walletLow', e?.cleared === true ? { remainingSeconds, cleared: true } : { remainingSeconds });
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
579
886
|
case 'roomClosed':
|
|
580
887
|
this.closedByUs = true;
|
|
581
888
|
this.emit('roomClosed', d);
|
|
582
|
-
this.
|
|
889
|
+
this.finish('room-closed');
|
|
890
|
+
// Before close() fails what is pending: an endRoom() this event
|
|
891
|
+
// answers succeeded.
|
|
892
|
+
for (const ended of this.endRoomWaiters)
|
|
893
|
+
ended();
|
|
894
|
+
this.endRoomWaiters.clear();
|
|
583
895
|
this.close();
|
|
584
896
|
break;
|
|
585
897
|
case 'kicked':
|
|
586
898
|
this.closedByUs = true;
|
|
587
899
|
this.emit('kicked', d);
|
|
588
|
-
this.
|
|
900
|
+
this.finish('kicked');
|
|
589
901
|
this.close();
|
|
590
902
|
break;
|
|
591
903
|
case 'message':
|
|
@@ -604,6 +916,25 @@ class StreamClient {
|
|
|
604
916
|
break;
|
|
605
917
|
}
|
|
606
918
|
}
|
|
919
|
+
/** Change the roster the join result holds; a new array each time, so a renderer sees the change. */
|
|
920
|
+
updateRoster(change) {
|
|
921
|
+
if (this.joinResult)
|
|
922
|
+
this.joinResult.participants = change(this.joinResult.participants ?? []);
|
|
923
|
+
}
|
|
924
|
+
/** Keep the join result's slots current: claims held or away, and the moderator's locks. */
|
|
925
|
+
applyClaim(e) {
|
|
926
|
+
const join = this.joinResult;
|
|
927
|
+
if (!join)
|
|
928
|
+
return;
|
|
929
|
+
const claims = (join.claims ?? []).filter((c) => c.key !== e.key);
|
|
930
|
+
let blocked = (join.blockedSlots ?? []).filter((key) => key !== e.key);
|
|
931
|
+
if ((e.state === 'held' || e.state === 'away') && e.identity)
|
|
932
|
+
claims.push({ key: e.key, identity: e.identity, state: e.state });
|
|
933
|
+
if (e.state === 'blocked')
|
|
934
|
+
blocked = [...blocked, e.key];
|
|
935
|
+
join.claims = claims;
|
|
936
|
+
join.blockedSlots = blocked;
|
|
937
|
+
}
|
|
607
938
|
// ------------------------------------------------------------------ media
|
|
608
939
|
ensureSendTransport() {
|
|
609
940
|
if (this.sendTransport)
|
|
@@ -617,7 +948,7 @@ class StreamClient {
|
|
|
617
948
|
}
|
|
618
949
|
async createSendTransport() {
|
|
619
950
|
if (!this.device)
|
|
620
|
-
throw new
|
|
951
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
621
952
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
622
953
|
const transport = this.device.createSendTransport({
|
|
623
954
|
...params,
|
|
@@ -629,7 +960,10 @@ class StreamClient {
|
|
|
629
960
|
.catch(fail);
|
|
630
961
|
});
|
|
631
962
|
transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
|
|
632
|
-
this.rpc('produce', {
|
|
963
|
+
this.rpc('produce', {
|
|
964
|
+
transportId: transport.id, kind, rtpParameters: declareAudioQuality(rtpParameters, appData),
|
|
965
|
+
appData, source: appData.source, videoLayers: appData.videoLayers,
|
|
966
|
+
})
|
|
633
967
|
.then((res) => ok({ id: res.producerId }))
|
|
634
968
|
.catch(fail);
|
|
635
969
|
});
|
|
@@ -648,7 +982,7 @@ class StreamClient {
|
|
|
648
982
|
}
|
|
649
983
|
async createRecvTransport() {
|
|
650
984
|
if (!this.device)
|
|
651
|
-
throw new
|
|
985
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
652
986
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
653
987
|
const transport = this.device.createRecvTransport({
|
|
654
988
|
...params,
|
|
@@ -675,12 +1009,24 @@ class StreamClient {
|
|
|
675
1009
|
* kept for a reconnect's republish.
|
|
676
1010
|
*/
|
|
677
1011
|
async publish(stream, constraints, options) {
|
|
1012
|
+
// A kind the project switched off is refused here, before anything is
|
|
1013
|
+
// produced, with the code the controller would answer with.
|
|
1014
|
+
const permissions = this.mediaPermissions;
|
|
1015
|
+
if (!permissions.audio && stream.getAudioTracks().length > 0) {
|
|
1016
|
+
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
1017
|
+
}
|
|
1018
|
+
if (!permissions.video && stream.getVideoTracks().length > 0) {
|
|
1019
|
+
throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
|
|
1020
|
+
}
|
|
1021
|
+
await this.publishStream(stream, constraints, options);
|
|
1022
|
+
}
|
|
1023
|
+
async publishStream(stream, constraints, options) {
|
|
678
1024
|
const generation = this.publishGeneration;
|
|
679
1025
|
const transport = await this.ensureSendTransport();
|
|
680
1026
|
// unpublish() ran first: this publish is over before it began, and must
|
|
681
1027
|
// leave nothing behind that a reconnect would publish again.
|
|
682
1028
|
if (generation !== this.publishGeneration)
|
|
683
|
-
throw
|
|
1029
|
+
throw unpublishedWhile('stream');
|
|
684
1030
|
this.localStream = stream;
|
|
685
1031
|
this.wasPublishing = true;
|
|
686
1032
|
if (constraints)
|
|
@@ -689,35 +1035,140 @@ class StreamClient {
|
|
|
689
1035
|
this.publishOptions = { ...options };
|
|
690
1036
|
this.frameRateFallback = false;
|
|
691
1037
|
this.frameRateLowSamples = 0;
|
|
1038
|
+
this.audioCapReported = false;
|
|
1039
|
+
this.sizeCapReported = false;
|
|
692
1040
|
}
|
|
1041
|
+
// Only a reconnect's republish gets here with a kind switched off since:
|
|
1042
|
+
// what is still allowed goes out, and the rest is reported.
|
|
1043
|
+
const permissions = this.mediaPermissions;
|
|
693
1044
|
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);
|
|
1045
|
+
if (audio && !permissions.audio) {
|
|
1046
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the microphone is not published'));
|
|
1047
|
+
}
|
|
1048
|
+
else if (audio) {
|
|
1049
|
+
await this.micTask(() => this.produceMic(transport, this.localStream?.getAudioTracks()[0] ?? audio, generation));
|
|
706
1050
|
}
|
|
707
1051
|
const video = stream.getVideoTracks()[0];
|
|
708
|
-
if (video) {
|
|
1052
|
+
if (video && !permissions.video) {
|
|
1053
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'video is switched off for this project; the camera is not published'));
|
|
1054
|
+
}
|
|
1055
|
+
else if (video) {
|
|
709
1056
|
// The track is read when the camera's turn comes: one handed to
|
|
710
1057
|
// replaceVideoTrack while this publish was under way is the one to send.
|
|
711
1058
|
await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
|
|
712
1059
|
}
|
|
713
1060
|
}
|
|
1061
|
+
// ------------------------------------------------------------ audio quality
|
|
1062
|
+
/**
|
|
1063
|
+
* The microphone's audio quality: what it is being sent at while
|
|
1064
|
+
* published, else what the next publish will use, after the project's limit.
|
|
1065
|
+
*/
|
|
1066
|
+
get audioQuality() {
|
|
1067
|
+
if (this.micProducer())
|
|
1068
|
+
return this.micQuality;
|
|
1069
|
+
return this.publishOptions.audioQuality === 'hd' && this.mediaPermissions.audioQualityMax === 'hd' ? 'hd' : 'std';
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Switch the microphone between standard (mono, up to 64 kbps) and HD
|
|
1073
|
+
* (stereo, up to 128 kbps) while live. The codec settings are fixed per
|
|
1074
|
+
* producer, so the microphone's producer is replaced: listeners hear a gap
|
|
1075
|
+
* of a fraction of a second, and a microphone that is off stays off. Rejects
|
|
1076
|
+
* with `audio-quality-not-allowed` when the project allows standard audio
|
|
1077
|
+
* only. Resolves to the quality in use.
|
|
1078
|
+
*/
|
|
1079
|
+
async setAudioQuality(quality) {
|
|
1080
|
+
if (quality !== 'std' && quality !== 'hd')
|
|
1081
|
+
throw new RangeError("setAudioQuality: expected 'std' or 'hd'");
|
|
1082
|
+
if (quality === 'hd' && this.mediaPermissions.audioQualityMax !== 'hd') {
|
|
1083
|
+
throw new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project');
|
|
1084
|
+
}
|
|
1085
|
+
this.publishOptions = { ...this.publishOptions, audioQuality: quality };
|
|
1086
|
+
this.audioCapReported = false;
|
|
1087
|
+
await this.micTask(async () => {
|
|
1088
|
+
const current = this.micProducer();
|
|
1089
|
+
const track = current?.track;
|
|
1090
|
+
if (!current || !track || track.readyState !== 'live' || this.micQuality === quality)
|
|
1091
|
+
return;
|
|
1092
|
+
await this.reproduceMic(current, track);
|
|
1093
|
+
});
|
|
1094
|
+
return this.audioQuality;
|
|
1095
|
+
}
|
|
1096
|
+
micProducer() {
|
|
1097
|
+
for (const p of this.producers.values()) {
|
|
1098
|
+
if (p.kind === 'audio' && p.appData.source === 'mic')
|
|
1099
|
+
return p;
|
|
1100
|
+
}
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
micTask(task) {
|
|
1104
|
+
const run = this.micQueue.then(task, task);
|
|
1105
|
+
this.micQueue = run.catch(() => undefined);
|
|
1106
|
+
return run;
|
|
1107
|
+
}
|
|
1108
|
+
/** The audio quality to send: the request, capped to the project's, with the cap reported once. */
|
|
1109
|
+
audioQualityFor() {
|
|
1110
|
+
if (this.publishOptions.audioQuality !== 'hd')
|
|
1111
|
+
return 'std';
|
|
1112
|
+
if (this.mediaPermissions.audioQualityMax === 'hd')
|
|
1113
|
+
return 'hd';
|
|
1114
|
+
if (!this.audioCapReported) {
|
|
1115
|
+
this.audioCapReported = true;
|
|
1116
|
+
this.emit('error', new errors_js_1.RpcError('audio-quality-not-allowed', 'HD audio is not enabled for this project; publishing standard audio'));
|
|
1117
|
+
}
|
|
1118
|
+
return 'std';
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Produce the microphone at the audio quality in force. A track handed over
|
|
1122
|
+
* switched off (or paused before a reconnect or a quality change) starts
|
|
1123
|
+
* paused, on both ends.
|
|
1124
|
+
*/
|
|
1125
|
+
async produceMic(transport, track, generation) {
|
|
1126
|
+
if (generation !== this.publishGeneration)
|
|
1127
|
+
throw unpublishedWhile('microphone');
|
|
1128
|
+
const quality = this.audioQualityFor();
|
|
1129
|
+
const settings = media_permissions_js_1.AUDIO_SETTINGS[quality];
|
|
1130
|
+
const producer = await transport.produce({
|
|
1131
|
+
track,
|
|
1132
|
+
// Keep the track alive when the producer closes: a reconnect closes
|
|
1133
|
+
// every producer, and a stopped track cannot be republished.
|
|
1134
|
+
stopTracks: false,
|
|
1135
|
+
// The answer's maxaveragebitrate sets the encoder's rate and the
|
|
1136
|
+
// sender's maxBitrate holds it there: standard must stay at or under
|
|
1137
|
+
// 64 kbps, which is what the server measures to bill it, so its sender
|
|
1138
|
+
// is held a little under the declared rate (Opus VBR overshoots).
|
|
1139
|
+
encodings: [{ maxBitrate: settings.senderMaxBitrate }],
|
|
1140
|
+
codecOptions: { opusStereo: settings.stereo, opusDtx: true, opusFec: true, opusMaxAverageBitrate: settings.maxBitrate },
|
|
1141
|
+
appData: { source: 'mic', audioQuality: quality },
|
|
1142
|
+
});
|
|
1143
|
+
await this.keepProducer(producer, generation, 'microphone');
|
|
1144
|
+
this.micQuality = quality;
|
|
1145
|
+
// A microphone handed over switched off, or muted before a reconnect.
|
|
1146
|
+
await this.pauseOnServer(producer);
|
|
1147
|
+
return producer;
|
|
1148
|
+
}
|
|
1149
|
+
/** Close the microphone producer and produce its track again at the quality now in force. */
|
|
1150
|
+
async reproduceMic(current, track) {
|
|
1151
|
+
const generation = this.publishGeneration;
|
|
1152
|
+
current.close();
|
|
1153
|
+
this.producers.delete(current.id);
|
|
1154
|
+
// The server allows one producer per source: the old one goes first.
|
|
1155
|
+
await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
|
|
1156
|
+
return this.produceMic(await this.ensureSendTransport(), track, generation);
|
|
1157
|
+
}
|
|
714
1158
|
/** What this project may publish. Known once joined; 1080p before that. */
|
|
715
1159
|
get videoPolicy() {
|
|
716
1160
|
return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
|
|
717
1161
|
}
|
|
718
|
-
/**
|
|
1162
|
+
/**
|
|
1163
|
+
* The quality video is being sent at, after the project's limit: at 60 fps
|
|
1164
|
+
* the largest size the project allows at 60, otherwise its 30 fps maximum.
|
|
1165
|
+
*/
|
|
719
1166
|
get videoQuality() {
|
|
720
|
-
return (0,
|
|
1167
|
+
return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, this.videoFrameRate, this.mediaPermissions);
|
|
1168
|
+
}
|
|
1169
|
+
/** The camera's frame rate: what it is being sent at while published, else 30 until a publish decides. */
|
|
1170
|
+
get videoFrameRate() {
|
|
1171
|
+
return this.cameraProducer() ? this.cameraFrameRate : 30;
|
|
721
1172
|
}
|
|
722
1173
|
/**
|
|
723
1174
|
* Change the video quality while publishing. The camera's producer is
|
|
@@ -727,6 +1178,7 @@ class StreamClient {
|
|
|
727
1178
|
*/
|
|
728
1179
|
async setVideoQuality(quality) {
|
|
729
1180
|
this.publishOptions = { ...this.publishOptions, quality };
|
|
1181
|
+
this.sizeCapReported = false;
|
|
730
1182
|
await this.cameraTask(async () => {
|
|
731
1183
|
const current = this.cameraProducer();
|
|
732
1184
|
const track = current?.track;
|
|
@@ -736,13 +1188,25 @@ class StreamClient {
|
|
|
736
1188
|
});
|
|
737
1189
|
return this.videoQuality;
|
|
738
1190
|
}
|
|
739
|
-
/**
|
|
1191
|
+
/**
|
|
1192
|
+
* Change the camera frame rate without stopping screen, microphone or music.
|
|
1193
|
+
* 60 where the project allows none stays at 30 and warns on `error` with
|
|
1194
|
+
* `frame-rate-not-allowed`, without touching the camera; a camera or device
|
|
1195
|
+
* that cannot keep 60 up falls back with `frame-rate-fallback`. Resolves to
|
|
1196
|
+
* the frame rate used.
|
|
1197
|
+
*/
|
|
740
1198
|
async setVideoFrameRate(frameRate) {
|
|
741
1199
|
if (frameRate !== 30 && frameRate !== 60)
|
|
742
1200
|
throw new RangeError('setVideoFrameRate: expected 30 or 60');
|
|
743
1201
|
this.publishOptions = { ...this.publishOptions, frameRate };
|
|
744
1202
|
this.frameRateFallback = false;
|
|
745
1203
|
this.frameRateLowSamples = 0;
|
|
1204
|
+
this.sizeCapReported = false;
|
|
1205
|
+
if (frameRate === 60 && !this.allows60()) {
|
|
1206
|
+
this.frameRateFallback = true;
|
|
1207
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1208
|
+
return this.videoFrameRate;
|
|
1209
|
+
}
|
|
746
1210
|
await this.cameraTask(async () => {
|
|
747
1211
|
const current = this.cameraProducer();
|
|
748
1212
|
const track = current?.track;
|
|
@@ -754,7 +1218,24 @@ class StreamClient {
|
|
|
754
1218
|
catch { /* Existing capture settings decide whether 60 fps is possible. */ }
|
|
755
1219
|
await this.reproduceCamera(current, track);
|
|
756
1220
|
});
|
|
757
|
-
return this.cameraProducer() ? this.cameraFrameRate : this.
|
|
1221
|
+
return this.cameraProducer() ? this.cameraFrameRate : this.allows60() ? frameRate : 30;
|
|
1222
|
+
}
|
|
1223
|
+
/** Whether this project allows any 60 fps mode. */
|
|
1224
|
+
allows60() {
|
|
1225
|
+
const permissions = this.mediaPermissions;
|
|
1226
|
+
return permissions.maxFrameRate === 60 && permissions.maxVideoQuality60 !== null;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The quality to send at a frame rate. A request above what 60 fps allows
|
|
1230
|
+
* is sent at the largest size it does allow, and said so once.
|
|
1231
|
+
*/
|
|
1232
|
+
cameraQuality(frameRate) {
|
|
1233
|
+
const permissions = this.mediaPermissions;
|
|
1234
|
+
if (frameRate === 60 && !this.sizeCapReported && (0, media_permissions_js_1.above60fpsCap)(this.publishOptions.quality, permissions)) {
|
|
1235
|
+
this.sizeCapReported = true;
|
|
1236
|
+
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`));
|
|
1237
|
+
}
|
|
1238
|
+
return (0, media_permissions_js_1.qualityAt)(this.publishOptions.quality, frameRate, permissions);
|
|
758
1239
|
}
|
|
759
1240
|
/**
|
|
760
1241
|
* Send a different video track from now on: a canvas with effects drawn on
|
|
@@ -800,7 +1281,8 @@ class StreamClient {
|
|
|
800
1281
|
const paused = current.paused;
|
|
801
1282
|
try {
|
|
802
1283
|
const { width = 0, height = 0 } = track.getSettings();
|
|
803
|
-
const
|
|
1284
|
+
const frameRate = this.frameRateFor(track);
|
|
1285
|
+
const next = (0, quality_js_1.videoEncodings)(width, height, this.cameraQuality(frameRate), this.layerCount(), frameRate);
|
|
804
1286
|
const previousSize = current.track?.getSettings();
|
|
805
1287
|
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
806
1288
|
previousSize?.width === width && previousSize?.height === height) {
|
|
@@ -942,7 +1424,7 @@ class StreamClient {
|
|
|
942
1424
|
layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
943
1425
|
}
|
|
944
1426
|
catch {
|
|
945
|
-
throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
1427
|
+
throw new errors_js_1.RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
|
|
946
1428
|
}
|
|
947
1429
|
}
|
|
948
1430
|
this.receiveQualities.set(producerId, quality);
|
|
@@ -993,7 +1475,7 @@ class StreamClient {
|
|
|
993
1475
|
*/
|
|
994
1476
|
async produceCamera(transport, track, generation, paused = !track.enabled) {
|
|
995
1477
|
if (generation !== this.publishGeneration)
|
|
996
|
-
throw
|
|
1478
|
+
throw unpublishedWhile('camera');
|
|
997
1479
|
// mediasoup-client starts a producer paused exactly when its track is
|
|
998
1480
|
// off. Setting the track to the camera's state first makes the producer
|
|
999
1481
|
// start in it, and a camera that is off never sends a frame of the new
|
|
@@ -1004,8 +1486,9 @@ class StreamClient {
|
|
|
1004
1486
|
// the device, so the browser sends every layer asked for (see
|
|
1005
1487
|
// videoEncodings).
|
|
1006
1488
|
const { width = 0, height = 0 } = track.getSettings();
|
|
1007
|
-
|
|
1489
|
+
// The frame rate first: the size the project allows depends on it.
|
|
1008
1490
|
this.cameraFrameRate = this.frameRateFor(track);
|
|
1491
|
+
const quality = this.cameraQuality(this.cameraFrameRate);
|
|
1009
1492
|
const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
|
|
1010
1493
|
let codecs;
|
|
1011
1494
|
try {
|
|
@@ -1055,7 +1538,7 @@ class StreamClient {
|
|
|
1055
1538
|
if (generation !== this.publishGeneration) {
|
|
1056
1539
|
producer.close();
|
|
1057
1540
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1058
|
-
throw
|
|
1541
|
+
throw unpublishedWhile(what);
|
|
1059
1542
|
}
|
|
1060
1543
|
this.producers.set(producer.id, producer);
|
|
1061
1544
|
}
|
|
@@ -1094,7 +1577,7 @@ class StreamClient {
|
|
|
1094
1577
|
/** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
|
|
1095
1578
|
reportCodecFallback(message) {
|
|
1096
1579
|
if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
|
|
1097
|
-
this.emit('error', new
|
|
1580
|
+
this.emit('error', new errors_js_1.RpcError('codec-fallback', message));
|
|
1098
1581
|
}
|
|
1099
1582
|
else if (!this.codecFallbackLogged) {
|
|
1100
1583
|
this.codecFallbackLogged = true;
|
|
@@ -1152,7 +1635,7 @@ class StreamClient {
|
|
|
1152
1635
|
const live = stream?.getTracks().some((t) => t.readyState === 'live') ?? false;
|
|
1153
1636
|
if (!live) {
|
|
1154
1637
|
if (!this.publishConstraints) {
|
|
1155
|
-
this.emit('error', new
|
|
1638
|
+
this.emit('error', new errors_js_1.RpcError('devices-gone', 'cannot restore the broadcast: the devices are gone'));
|
|
1156
1639
|
return;
|
|
1157
1640
|
}
|
|
1158
1641
|
const ended = stream;
|
|
@@ -1174,20 +1657,22 @@ class StreamClient {
|
|
|
1174
1657
|
}
|
|
1175
1658
|
if (!stream)
|
|
1176
1659
|
return;
|
|
1177
|
-
await this.
|
|
1660
|
+
await this.publishStream(stream);
|
|
1178
1661
|
}
|
|
1179
1662
|
// ------------------------------------------------------------- screen sharing
|
|
1180
1663
|
/** Publish a screen beside the camera. Call from a click so capture permission can open. */
|
|
1181
1664
|
async publishScreen(options = {}) {
|
|
1182
1665
|
if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
|
|
1183
|
-
throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1666
|
+
throw new errors_js_1.RpcError('forbidden', 'only hosts and cohosts can share a screen');
|
|
1667
|
+
if (!this.mediaPermissions.video)
|
|
1668
|
+
throw new errors_js_1.RpcError('media-disabled', 'video is switched off for this project');
|
|
1184
1669
|
if (this.screenStream || this.screenPending)
|
|
1185
|
-
throw new RpcError('screen-share-active', 'a screen share is already active');
|
|
1670
|
+
throw new errors_js_1.RpcError('screen-share-active', 'a screen share is already active');
|
|
1186
1671
|
if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
|
|
1187
1672
|
throw new TypeError('publishScreen: mode must be detail or motion');
|
|
1188
1673
|
const mode = options.mode ?? 'detail';
|
|
1189
1674
|
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');
|
|
1675
|
+
throw new errors_js_1.RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
|
|
1191
1676
|
}
|
|
1192
1677
|
const generation = this.screenGeneration;
|
|
1193
1678
|
this.screenPending = true;
|
|
@@ -1199,9 +1684,9 @@ class StreamClient {
|
|
|
1199
1684
|
});
|
|
1200
1685
|
const video = stream.getVideoTracks()[0];
|
|
1201
1686
|
if (!video || video.readyState !== 'live')
|
|
1202
|
-
throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1687
|
+
throw new errors_js_1.RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
|
|
1203
1688
|
if (generation !== this.screenGeneration || this.closedByUs)
|
|
1204
|
-
throw
|
|
1689
|
+
throw unpublishedWhile('screen');
|
|
1205
1690
|
this.screenStream = stream;
|
|
1206
1691
|
this.screenMode = mode;
|
|
1207
1692
|
this.screenAudioEnabled = options.audio !== false;
|
|
@@ -1211,7 +1696,7 @@ class StreamClient {
|
|
|
1211
1696
|
video.addEventListener('ended', onEnded, { once: true });
|
|
1212
1697
|
await this.restoreScreen();
|
|
1213
1698
|
if (generation !== this.screenGeneration || video.readyState !== 'live')
|
|
1214
|
-
throw
|
|
1699
|
+
throw unpublishedWhile('screen');
|
|
1215
1700
|
this.screenStarted = true;
|
|
1216
1701
|
this.wasPublishing = true;
|
|
1217
1702
|
this.emit('screenShareStarted', { stream, mode });
|
|
@@ -1275,7 +1760,7 @@ class StreamClient {
|
|
|
1275
1760
|
const { width = 0, height = 0 } = video.getSettings();
|
|
1276
1761
|
const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
|
|
1277
1762
|
// A project's explicit safety restriction also applies to screen video.
|
|
1278
|
-
const cap = (0, quality_js_1.topLayerSize)(width, height, this.
|
|
1763
|
+
const cap = (0, quality_js_1.topLayerSize)(width, height, this.mediaPermissions.maxVideoQuality);
|
|
1279
1764
|
const screenScale = encodings.at(-1).scaleResolutionDownBy;
|
|
1280
1765
|
if (cap.scale > screenScale)
|
|
1281
1766
|
encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
|
|
@@ -1283,7 +1768,7 @@ class StreamClient {
|
|
|
1283
1768
|
if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
|
|
1284
1769
|
producer.close();
|
|
1285
1770
|
await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
|
|
1286
|
-
throw
|
|
1771
|
+
throw unpublishedWhile('screen');
|
|
1287
1772
|
}
|
|
1288
1773
|
this.producers.set(producer.id, producer);
|
|
1289
1774
|
this.screenProducerIds.add(producer.id);
|
|
@@ -1294,7 +1779,12 @@ class StreamClient {
|
|
|
1294
1779
|
codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
|
|
1295
1780
|
appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
|
|
1296
1781
|
}));
|
|
1297
|
-
|
|
1782
|
+
let audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
|
|
1783
|
+
if (audio && this.screenAudioEnabled && !this.mediaPermissions.audio) {
|
|
1784
|
+
// The screen goes out; its sound cannot in a project without audio.
|
|
1785
|
+
this.emit('error', new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project; the screen is shared without its sound'));
|
|
1786
|
+
audio = undefined;
|
|
1787
|
+
}
|
|
1298
1788
|
if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
|
|
1299
1789
|
await keep(await transport.produce({
|
|
1300
1790
|
track: audio, stopTracks: false,
|
|
@@ -1302,13 +1792,25 @@ class StreamClient {
|
|
|
1302
1792
|
appData: { source: 'screen-audio' },
|
|
1303
1793
|
}));
|
|
1304
1794
|
}
|
|
1795
|
+
/**
|
|
1796
|
+
* 60 only when asked for, allowed by the project, captured at 50 or more and
|
|
1797
|
+
* not fallen back from. The project's refusal and the camera's are reported
|
|
1798
|
+
* apart, each once: `frame-rate-not-allowed` is the server's own code for a
|
|
1799
|
+
* 60 fps producer the project does not allow, `frame-rate-fallback` means
|
|
1800
|
+
* the camera or device cannot provide it.
|
|
1801
|
+
*/
|
|
1305
1802
|
frameRateFor(track) {
|
|
1306
1803
|
if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
|
|
1307
1804
|
return 30;
|
|
1805
|
+
if (!this.allows60()) {
|
|
1806
|
+
this.frameRateFallback = true;
|
|
1807
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-not-allowed', '60 fps is not enabled for this project; publishing at 30 fps'));
|
|
1808
|
+
return 30;
|
|
1809
|
+
}
|
|
1308
1810
|
const captured = track.getSettings().frameRate;
|
|
1309
|
-
if (
|
|
1811
|
+
if (typeof captured === 'number' && captured < 50) {
|
|
1310
1812
|
this.frameRateFallback = true;
|
|
1311
|
-
this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera
|
|
1813
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', '60 fps is unavailable from this camera; publishing at 30 fps'));
|
|
1312
1814
|
return 30;
|
|
1313
1815
|
}
|
|
1314
1816
|
return 60;
|
|
@@ -1338,7 +1840,7 @@ class StreamClient {
|
|
|
1338
1840
|
this.frameRateFallback = true;
|
|
1339
1841
|
await producer.track.applyConstraints?.({ ...producer.track.getConstraints?.(), frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
|
|
1340
1842
|
await this.reproduceCamera(producer, producer.track);
|
|
1341
|
-
this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1843
|
+
this.emit('error', new errors_js_1.RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
|
|
1342
1844
|
}).catch((error) => this.emit('error', error));
|
|
1343
1845
|
}
|
|
1344
1846
|
return stats;
|
|
@@ -1355,6 +1857,8 @@ class StreamClient {
|
|
|
1355
1857
|
* the user has not interacted with.
|
|
1356
1858
|
*/
|
|
1357
1859
|
async publishMusic(input, options) {
|
|
1860
|
+
if (!this.mediaPermissions.audio)
|
|
1861
|
+
throw new errors_js_1.RpcError('media-disabled', 'audio is switched off for this project');
|
|
1358
1862
|
const generation = this.publishGeneration;
|
|
1359
1863
|
if (this.music)
|
|
1360
1864
|
await this.stopMusic();
|
|
@@ -1365,7 +1869,7 @@ class StreamClient {
|
|
|
1365
1869
|
// already closed it, and the music must not be remembered for a
|
|
1366
1870
|
// reconnect to publish again.
|
|
1367
1871
|
if (generation !== this.publishGeneration)
|
|
1368
|
-
throw
|
|
1872
|
+
throw unpublishedWhile('music');
|
|
1369
1873
|
}
|
|
1370
1874
|
catch (err) {
|
|
1371
1875
|
music.close();
|
|
@@ -1378,7 +1882,7 @@ class StreamClient {
|
|
|
1378
1882
|
async produceMusic(music, generation) {
|
|
1379
1883
|
const transport = await this.ensureSendTransport();
|
|
1380
1884
|
if (generation !== this.publishGeneration)
|
|
1381
|
-
throw
|
|
1885
|
+
throw unpublishedWhile('music');
|
|
1382
1886
|
const producer = await transport.produce({
|
|
1383
1887
|
track: music.track,
|
|
1384
1888
|
stopTracks: false,
|
|
@@ -1438,13 +1942,49 @@ class StreamClient {
|
|
|
1438
1942
|
}
|
|
1439
1943
|
async consume(remote) {
|
|
1440
1944
|
if (!this.device)
|
|
1441
|
-
throw new
|
|
1945
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1442
1946
|
if (this.consumers.has(remote.producerId))
|
|
1443
1947
|
return;
|
|
1444
1948
|
// Your own camera is already local; pulling it back off the SFU wastes a
|
|
1445
1949
|
// consumer and shows you a delayed copy of yourself.
|
|
1446
1950
|
if (remote.identity === this.joinResult?.identity)
|
|
1447
1951
|
return;
|
|
1952
|
+
// A wallet session receives at most media.fanout.maxVideoSubscriptions
|
|
1953
|
+
// videos; the media node refuses the next. Wait for a slot instead of
|
|
1954
|
+
// asking for a refusal, and say so once per video.
|
|
1955
|
+
if (remote.kind === 'video' && !this.videoSlots.has(remote.producerId)) {
|
|
1956
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1957
|
+
if (limit !== null && this.videoSlots.size >= limit) {
|
|
1958
|
+
if (!this.skippedVideos.has(remote.producerId)) {
|
|
1959
|
+
this.skippedVideos.set(remote.producerId, remote);
|
|
1960
|
+
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`));
|
|
1961
|
+
}
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
this.videoSlots.add(remote.producerId);
|
|
1965
|
+
}
|
|
1966
|
+
try {
|
|
1967
|
+
await this.consumeNow(remote);
|
|
1968
|
+
}
|
|
1969
|
+
catch (err) {
|
|
1970
|
+
if (!this.consumers.has(remote.producerId))
|
|
1971
|
+
this.videoSlots.delete(remote.producerId);
|
|
1972
|
+
throw err;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/** The oldest videos left waiting at the fan-out limit take the slots that opened. */
|
|
1976
|
+
async consumeSkipped() {
|
|
1977
|
+
for (const [producerId, remote] of this.skippedVideos) {
|
|
1978
|
+
const limit = this.mediaPermissions.maxVideoSubscriptions;
|
|
1979
|
+
if (limit !== null && this.videoSlots.size >= limit)
|
|
1980
|
+
return;
|
|
1981
|
+
this.skippedVideos.delete(producerId);
|
|
1982
|
+
await this.consume(remote);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
async consumeNow(remote) {
|
|
1986
|
+
if (!this.device)
|
|
1987
|
+
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1448
1988
|
const transport = await this.ensureRecvTransport();
|
|
1449
1989
|
const params = (await this.rpc('consume', {
|
|
1450
1990
|
transportId: transport.id,
|
|
@@ -1465,7 +2005,7 @@ class StreamClient {
|
|
|
1465
2005
|
}
|
|
1466
2006
|
catch {
|
|
1467
2007
|
layers = undefined;
|
|
1468
|
-
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
2008
|
+
this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
1469
2009
|
}
|
|
1470
2010
|
}
|
|
1471
2011
|
if (layers) {
|
|
@@ -1477,6 +2017,7 @@ class StreamClient {
|
|
|
1477
2017
|
const kind = consumer.kind;
|
|
1478
2018
|
const source = remote.source ?? defaultSource(kind);
|
|
1479
2019
|
this.remoteSources.set(remote.producerId, source);
|
|
2020
|
+
this.remoteInfo.set(remote.producerId, { identity: remote.identity, kind, paused: Boolean(remote.paused) });
|
|
1480
2021
|
this.emit('track', {
|
|
1481
2022
|
identity: remote.identity,
|
|
1482
2023
|
kind,
|
|
@@ -1619,8 +2160,72 @@ class StreamClient {
|
|
|
1619
2160
|
}
|
|
1620
2161
|
promote(identity, role) { return this.rpc('promote', { identity, role }); }
|
|
1621
2162
|
kick(identity, reason) { return this.rpc('kick', { identity, reason }); }
|
|
1622
|
-
|
|
2163
|
+
/**
|
|
2164
|
+
* End the room for everyone. The server tells the room it has closed
|
|
2165
|
+
* before it replies, and that roomClosed closes this session, so the reply
|
|
2166
|
+
* may never be read: roomClosed is then the answer, and endRoom() resolves
|
|
2167
|
+
* as the reply would have, with `{ ok: true }`. It still fails when the
|
|
2168
|
+
* server refuses, or the connection drops with neither.
|
|
2169
|
+
*/
|
|
2170
|
+
endRoom() {
|
|
2171
|
+
return new Promise((resolve, reject) => {
|
|
2172
|
+
const ended = () => resolve({ ok: true });
|
|
2173
|
+
this.endRoomWaiters.add(ended);
|
|
2174
|
+
this.rpc('endRoom').then(resolve, reject).finally(() => this.endRoomWaiters.delete(ended));
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
1623
2177
|
getRoomState() { return this.rpc('getRoomState'); }
|
|
2178
|
+
// ------------------------------------------------------- slots and metadata
|
|
2179
|
+
//
|
|
2180
|
+
// A slot is a named thing in the room that at most one participant holds at
|
|
2181
|
+
// a time: a seat on a board, a guest spot. Keys are yours; the server never
|
|
2182
|
+
// learns that `seat-13` is a seat. `info.claims` and `info.blockedSlots`
|
|
2183
|
+
// seed a board on join and follow claimChanged.
|
|
2184
|
+
/**
|
|
2185
|
+
* Take a slot, or (moderators) grant one by naming `identity`. One claim
|
|
2186
|
+
* per identity: a second moves you. Refused with `slot-taken` when someone
|
|
2187
|
+
* holds it (away included) and `slot-blocked` when a moderator locked it.
|
|
2188
|
+
* Claim first, then promote: if the slot has gone, nothing has happened yet.
|
|
2189
|
+
*/
|
|
2190
|
+
async claim(key, identity) {
|
|
2191
|
+
await this.rpc('claim', identity ? { key, identity } : { key });
|
|
2192
|
+
}
|
|
2193
|
+
/** Give up a slot. Moderators may release anyone's. */
|
|
2194
|
+
async release(key) {
|
|
2195
|
+
await this.rpc('release', { key });
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Lock a slot so nobody may claim it. Moderators only. Refused with
|
|
2199
|
+
* `slot-occupied` while someone holds it: taking a seat from a person using
|
|
2200
|
+
* it is two deliberate acts, not a side effect of one tap.
|
|
2201
|
+
*/
|
|
2202
|
+
async blockSlot(key) {
|
|
2203
|
+
await this.rpc('blockSlot', { key });
|
|
2204
|
+
}
|
|
2205
|
+
/** Unlock a slot. Moderators only. */
|
|
2206
|
+
async unblockSlot(key) {
|
|
2207
|
+
await this.rpc('unblockSlot', { key });
|
|
2208
|
+
}
|
|
2209
|
+
/** Replace your own display metadata; the room hears it as participantUpdated. */
|
|
2210
|
+
async setMetadata(metadata) {
|
|
2211
|
+
await this.rpc('setMetadata', { metadata });
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Change room-scoped metadata. Moderators only, and persisted, so a client
|
|
2215
|
+
* joining later reads it from `info.metadata`. Merges one key deep by
|
|
2216
|
+
* default (a null value deletes its key); `replace` swaps the whole object.
|
|
2217
|
+
* `requireFree` names slots that must be unheld for the change to apply,
|
|
2218
|
+
* checked and applied together: the refusal (`slot-occupied`) names the
|
|
2219
|
+
* offending keys in `RpcError.data.slots`. Resolves to the metadata now.
|
|
2220
|
+
*/
|
|
2221
|
+
async setRoomMetadata(metadata, options = {}) {
|
|
2222
|
+
const res = (await this.rpc('setRoomMetadata', {
|
|
2223
|
+
metadata,
|
|
2224
|
+
...(options.replace ? { replace: true } : {}),
|
|
2225
|
+
...(options.requireFree ? { requireFree: options.requireFree } : {}),
|
|
2226
|
+
}));
|
|
2227
|
+
return res?.metadata ?? null;
|
|
2228
|
+
}
|
|
1624
2229
|
// ------------------------------------------------------------------- chat
|
|
1625
2230
|
//
|
|
1626
2231
|
// The room's chat rides this same socket. Someone on a chat-only socket
|