@voltkit-rtc/client 0.7.2 → 0.8.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 +89 -0
- package/README.md +255 -4
- package/dist/cjs/adaptive.d.ts +104 -0
- package/dist/cjs/adaptive.d.ts.map +1 -0
- package/dist/cjs/adaptive.js +263 -0
- package/dist/cjs/adaptive.js.map +1 -0
- package/dist/cjs/devices.d.ts +42 -0
- package/dist/cjs/devices.d.ts.map +1 -0
- package/dist/cjs/devices.js +56 -0
- package/dist/cjs/devices.js.map +1 -0
- package/dist/cjs/diagnostics.d.ts +65 -0
- package/dist/cjs/diagnostics.d.ts.map +1 -0
- package/dist/cjs/diagnostics.js +69 -0
- package/dist/cjs/diagnostics.js.map +1 -0
- package/dist/cjs/fallback.d.ts +81 -0
- package/dist/cjs/fallback.d.ts.map +1 -0
- package/dist/cjs/fallback.js +148 -0
- package/dist/cjs/fallback.js.map +1 -0
- package/dist/cjs/health.d.ts +70 -0
- package/dist/cjs/health.d.ts.map +1 -0
- package/dist/cjs/health.js +116 -0
- package/dist/cjs/health.js.map +1 -0
- package/dist/cjs/index.d.ts +350 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1201 -36
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/link.d.ts +99 -0
- package/dist/cjs/link.d.ts.map +1 -0
- package/dist/cjs/link.js +219 -0
- package/dist/cjs/link.js.map +1 -0
- package/dist/cjs/preflight.d.ts +79 -0
- package/dist/cjs/preflight.d.ts.map +1 -0
- package/dist/cjs/preflight.js +159 -0
- package/dist/cjs/preflight.js.map +1 -0
- package/dist/cjs/recording.d.ts +14 -0
- package/dist/cjs/recording.d.ts.map +1 -1
- package/dist/cjs/recording.js +51 -5
- package/dist/cjs/recording.js.map +1 -1
- package/dist/cjs/speakers.d.ts +34 -0
- package/dist/cjs/speakers.d.ts.map +1 -0
- package/dist/cjs/speakers.js +71 -0
- package/dist/cjs/speakers.js.map +1 -0
- package/dist/esm/adaptive.d.ts +104 -0
- package/dist/esm/adaptive.d.ts.map +1 -0
- package/dist/esm/adaptive.js +256 -0
- package/dist/esm/adaptive.js.map +1 -0
- package/dist/esm/devices.d.ts +42 -0
- package/dist/esm/devices.d.ts.map +1 -0
- package/dist/esm/devices.js +50 -0
- package/dist/esm/devices.js.map +1 -0
- package/dist/esm/diagnostics.d.ts +65 -0
- package/dist/esm/diagnostics.d.ts.map +1 -0
- package/dist/esm/diagnostics.js +65 -0
- package/dist/esm/diagnostics.js.map +1 -0
- package/dist/esm/fallback.d.ts +81 -0
- package/dist/esm/fallback.d.ts.map +1 -0
- package/dist/esm/fallback.js +142 -0
- package/dist/esm/fallback.js.map +1 -0
- package/dist/esm/health.d.ts +70 -0
- package/dist/esm/health.d.ts.map +1 -0
- package/dist/esm/health.js +111 -0
- package/dist/esm/health.js.map +1 -0
- package/dist/esm/index.d.ts +350 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1199 -36
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/link.d.ts +99 -0
- package/dist/esm/link.d.ts.map +1 -0
- package/dist/esm/link.js +214 -0
- package/dist/esm/link.js.map +1 -0
- package/dist/esm/preflight.d.ts +79 -0
- package/dist/esm/preflight.d.ts.map +1 -0
- package/dist/esm/preflight.js +153 -0
- package/dist/esm/preflight.js.map +1 -0
- package/dist/esm/recording.d.ts +14 -0
- package/dist/esm/recording.d.ts.map +1 -1
- package/dist/esm/recording.js +51 -5
- package/dist/esm/recording.js.map +1 -1
- package/dist/esm/speakers.d.ts +34 -0
- package/dist/esm/speakers.d.ts.map +1 -0
- package/dist/esm/speakers.js +67 -0
- package/dist/esm/speakers.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive.ts +295 -0
- package/src/devices.ts +70 -0
- package/src/diagnostics.ts +94 -0
- package/src/fallback.ts +169 -0
- package/src/health.ts +127 -0
- package/src/index.ts +1192 -36
- package/src/link.ts +255 -0
- package/src/preflight.ts +191 -0
- package/src/recording.ts +59 -21
- package/src/speakers.ts +66 -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.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;
|
|
3
|
+
exports.StreamClient = exports.CLIENT_EVENTS = exports.CONNECTION_STATES = exports.SDK_VERSION = exports.SPEAKING_LEVEL = 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.
|
|
@@ -13,6 +13,14 @@ const mediasoup_client_1 = require("mediasoup-client");
|
|
|
13
13
|
const errors_js_1 = require("./errors.js");
|
|
14
14
|
const media_permissions_js_1 = require("./media-permissions.js");
|
|
15
15
|
const network_js_1 = require("./network.js");
|
|
16
|
+
const link_js_1 = require("./link.js");
|
|
17
|
+
const adaptive_js_1 = require("./adaptive.js");
|
|
18
|
+
const fallback_js_1 = require("./fallback.js");
|
|
19
|
+
const speakers_js_1 = require("./speakers.js");
|
|
20
|
+
const preflight_js_1 = require("./preflight.js");
|
|
21
|
+
const diagnostics_js_1 = require("./diagnostics.js");
|
|
22
|
+
const health_js_1 = require("./health.js");
|
|
23
|
+
const devices_js_1 = require("./devices.js");
|
|
16
24
|
const token_js_1 = require("./token.js");
|
|
17
25
|
const device_js_1 = require("./device.js");
|
|
18
26
|
const music_js_1 = require("./music.js");
|
|
@@ -55,19 +63,43 @@ Object.defineProperty(exports, "allowedQuality", { enumerable: true, get: functi
|
|
|
55
63
|
Object.defineProperty(exports, "layerBitrate", { enumerable: true, get: function () { return quality_js_2.layerBitrate; } });
|
|
56
64
|
Object.defineProperty(exports, "videoEncodings", { enumerable: true, get: function () { return quality_js_2.videoEncodings; } });
|
|
57
65
|
Object.defineProperty(exports, "VIDEO_QUALITIES", { enumerable: true, get: function () { return quality_js_2.VIDEO_QUALITIES; } });
|
|
66
|
+
var speakers_js_2 = require("./speakers.js");
|
|
67
|
+
Object.defineProperty(exports, "SPEAKING_LEVEL", { enumerable: true, get: function () { return speakers_js_2.SPEAKING_LEVEL; } });
|
|
68
|
+
var diagnostics_js_2 = require("./diagnostics.js");
|
|
69
|
+
Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return diagnostics_js_2.SDK_VERSION; } });
|
|
58
70
|
/** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
|
|
59
71
|
exports.CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
|
|
60
72
|
const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
|
|
73
|
+
/** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
|
|
74
|
+
function receiverLevel(receiver) {
|
|
75
|
+
const sources = receiver?.getSynchronizationSources?.() ?? [];
|
|
76
|
+
let level = 0;
|
|
77
|
+
for (const s of sources)
|
|
78
|
+
if (typeof s.audioLevel === 'number')
|
|
79
|
+
level = Math.max(level, s.audioLevel);
|
|
80
|
+
return level;
|
|
81
|
+
}
|
|
82
|
+
/** A microphone producer's level now (0..1), from its media-source statistics. */
|
|
83
|
+
async function senderLevel(producer) {
|
|
84
|
+
const stats = await producer.getStats();
|
|
85
|
+
for (const s of stats.values()) {
|
|
86
|
+
if (s.type === 'media-source' && typeof s.audioLevel === 'number')
|
|
87
|
+
return s.audioLevel;
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
61
91
|
// Every event a StreamClient emits. Typed against ClientEvents, so adding an
|
|
62
92
|
// event without listing it here (or listing one that does not exist) fails to
|
|
63
93
|
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
64
94
|
const EVENT_NAMES = {
|
|
95
|
+
mediaReconnecting: true,
|
|
65
96
|
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
66
97
|
track: true, trackEnded: true, trackMuted: true,
|
|
67
98
|
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
68
99
|
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
69
100
|
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
70
|
-
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
101
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
|
|
102
|
+
streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
|
|
71
103
|
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
72
104
|
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
73
105
|
};
|
|
@@ -79,7 +111,6 @@ const MAX_RECONNECT_ATTEMPTS = 8;
|
|
|
79
111
|
const CLOSE_REPLACED = 4001;
|
|
80
112
|
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
81
113
|
const CLOSE_ENDED = 4003;
|
|
82
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
83
114
|
const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
84
115
|
/**
|
|
85
116
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
@@ -89,6 +120,16 @@ const VIDEO_START_BITRATE_KBPS = 2000;
|
|
|
89
120
|
/** Why a connect() gave up: close() ran while it was under way, and close wins. */
|
|
90
121
|
const closedWhileConnecting = () => new errors_js_1.RpcError('not-connected', 'the session was closed while connecting');
|
|
91
122
|
/** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
|
|
123
|
+
/**
|
|
124
|
+
* The constraints that capture again what two publishes captured: each kind
|
|
125
|
+
* as the later publish asked for it, or as the earlier one did if the later
|
|
126
|
+
* one did not ask for it.
|
|
127
|
+
*/
|
|
128
|
+
function mergeConstraints(earlier, later) {
|
|
129
|
+
if (!earlier)
|
|
130
|
+
return later;
|
|
131
|
+
return { ...earlier, ...later, audio: later.audio || earlier.audio, video: later.video || earlier.video };
|
|
132
|
+
}
|
|
92
133
|
const unpublishedWhile = (what) => new errors_js_1.RpcError('unpublished', `the ${what} was unpublished while it was being published`);
|
|
93
134
|
/**
|
|
94
135
|
* mediasoup-client puts the microphone's Opus maxaveragebitrate in the answer
|
|
@@ -112,6 +153,78 @@ function declareAudioQuality(rtpParameters, appData) {
|
|
|
112
153
|
class StreamClient {
|
|
113
154
|
wsUrl;
|
|
114
155
|
options;
|
|
156
|
+
backoffWake = null;
|
|
157
|
+
onlineTarget = null;
|
|
158
|
+
online = () => {
|
|
159
|
+
if (this.closedByUs)
|
|
160
|
+
return;
|
|
161
|
+
this.backoffWake?.();
|
|
162
|
+
this.socketLiveness.checkNow();
|
|
163
|
+
};
|
|
164
|
+
socketLiveness = new link_js_1.SocketLiveness({
|
|
165
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
166
|
+
ping: () => { void this.rpc('ping').catch(() => undefined); },
|
|
167
|
+
dead: () => this.recoverConnection('socket-timeout'),
|
|
168
|
+
});
|
|
169
|
+
mediaPaths = new link_js_1.MediaPathWatch({
|
|
170
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
171
|
+
restartIce: id => this.restartMediaIce(id),
|
|
172
|
+
reconnect: reason => this.recoverConnection(reason),
|
|
173
|
+
onPathChange: (transportId, reconnecting) => {
|
|
174
|
+
if (!this.closedByUs)
|
|
175
|
+
this.emit('mediaReconnecting', { transportId, reconnecting });
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
recoverConnection(reason) {
|
|
179
|
+
if (this.closedByUs || !this.joinedOnce || this.state !== 'connected')
|
|
180
|
+
return;
|
|
181
|
+
this.setState('reconnecting');
|
|
182
|
+
if (this.closedByUs)
|
|
183
|
+
return; // a state listener may close synchronously
|
|
184
|
+
this.mediaPaths.reset();
|
|
185
|
+
if (this.closedByUs)
|
|
186
|
+
return;
|
|
187
|
+
this.detachSocket(reason);
|
|
188
|
+
this.networkWatch.set(false);
|
|
189
|
+
this.emit('disconnected', reason);
|
|
190
|
+
void this.scheduleReconnect();
|
|
191
|
+
}
|
|
192
|
+
async restartMediaIce(id) {
|
|
193
|
+
const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
|
|
194
|
+
const socket = this.ws;
|
|
195
|
+
if (!transport || transport.closed || this.closedByUs || this.state !== 'connected')
|
|
196
|
+
return 'skipped';
|
|
197
|
+
const { iceParameters } = await this.rpc('restartIce', { transportId: id });
|
|
198
|
+
if (this.ws !== socket || transport.closed || this.closedByUs ||
|
|
199
|
+
(transport !== this.sendTransport && transport !== this.recvTransport))
|
|
200
|
+
return 'skipped';
|
|
201
|
+
await transport.restartIce({ iceParameters });
|
|
202
|
+
if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
|
|
203
|
+
this.mediaPaths.update(id, 'connected');
|
|
204
|
+
}
|
|
205
|
+
return 'restarted';
|
|
206
|
+
}
|
|
207
|
+
watchTransport(transport) {
|
|
208
|
+
transport.on('connectionstatechange', state => {
|
|
209
|
+
if (transport !== this.sendTransport && transport !== this.recvTransport)
|
|
210
|
+
return;
|
|
211
|
+
if (!this.closedByUs && this.state === 'connected')
|
|
212
|
+
this.mediaPaths.update(transport.id, state);
|
|
213
|
+
});
|
|
214
|
+
transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
|
|
215
|
+
}
|
|
216
|
+
waitBackoff(ms) {
|
|
217
|
+
return new Promise(resolve => {
|
|
218
|
+
const finish = () => {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
if (this.backoffWake === finish)
|
|
221
|
+
this.backoffWake = null;
|
|
222
|
+
resolve();
|
|
223
|
+
};
|
|
224
|
+
const timer = setTimeout(finish, ms);
|
|
225
|
+
this.backoffWake = finish;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
115
228
|
ws = null;
|
|
116
229
|
device = null;
|
|
117
230
|
sendTransport = null;
|
|
@@ -136,6 +249,15 @@ class StreamClient {
|
|
|
136
249
|
// broadcaster that reconnects rejoins silently, which is worse than being
|
|
137
250
|
// disconnected: the room looks healthy and shows nothing.
|
|
138
251
|
wasPublishing = false;
|
|
252
|
+
/** The deadline of the socket being opened (openSocket). */
|
|
253
|
+
openDeadline;
|
|
254
|
+
/**
|
|
255
|
+
* This client's own moderation per kind: whether a moderator holds it off,
|
|
256
|
+
* and a revision every decision bumps. A switch-on that was waiting for the
|
|
257
|
+
* server when a decision arrived does not act on its reply (V072-06).
|
|
258
|
+
*/
|
|
259
|
+
forcedOff = { audio: false, video: false };
|
|
260
|
+
moderationRevision = { audio: 0, video: 0 };
|
|
139
261
|
publishConstraints = null;
|
|
140
262
|
publishOptions = {};
|
|
141
263
|
/** In-app music being published, kept across reconnects so it can be published again. */
|
|
@@ -175,6 +297,67 @@ class StreamClient {
|
|
|
175
297
|
* the full picture.
|
|
176
298
|
*/
|
|
177
299
|
preferredLayers = new Map();
|
|
300
|
+
/**
|
|
301
|
+
* Adaptive stream (attachVideo): each attached remote video at the size it is
|
|
302
|
+
* shown, and paused at the server while no attached element is visible.
|
|
303
|
+
*/
|
|
304
|
+
adaptive = new adaptive_js_1.AdaptiveVideos({
|
|
305
|
+
layers: (id) => this.remoteVideoLayers.get(id) ?? [],
|
|
306
|
+
apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
|
|
307
|
+
pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
|
|
308
|
+
pageVisible: adaptive_js_1.pageIsVisible,
|
|
309
|
+
});
|
|
310
|
+
/** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
|
|
311
|
+
audioOnlyEnabled = true;
|
|
312
|
+
protection = new fallback_js_1.AudioProtection((active) => this.onAudioProtection(active));
|
|
313
|
+
protectionTimer = null;
|
|
314
|
+
protectionSampling = false;
|
|
315
|
+
/** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
|
|
316
|
+
publishFallbackEnabled = false;
|
|
317
|
+
publishProtection = new fallback_js_1.AudioProtection((active) => { void this.onPublishProtection(active); });
|
|
318
|
+
publishProtectionTimer = null;
|
|
319
|
+
publishProtectionSampling = false;
|
|
320
|
+
cameraHeldByFallback = false;
|
|
321
|
+
/** Remote videos the media server reports it cut for this viewer's bandwidth. */
|
|
322
|
+
bandwidthCut = new Set();
|
|
323
|
+
/** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
|
|
324
|
+
protectionHeld = new Set();
|
|
325
|
+
/** audioLevels / activeSpeakers: read only while someone listens. */
|
|
326
|
+
levelTimer = null;
|
|
327
|
+
levelSampling = false;
|
|
328
|
+
speakers = new speakers_js_1.ActiveSpeakers();
|
|
329
|
+
/** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
|
|
330
|
+
health = new health_js_1.HealthDecisions((warning) => this.emit('mediaWarning', warning));
|
|
331
|
+
healthTimer = null;
|
|
332
|
+
healthSampling = false;
|
|
333
|
+
interruptions = new Map();
|
|
334
|
+
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
335
|
+
diagnosticsLog = new diagnostics_js_1.DiagnosticsLog();
|
|
336
|
+
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
337
|
+
autoplayBlocked = new Set();
|
|
338
|
+
gestureWatchOn = false;
|
|
339
|
+
onGesture = () => { void this.retryAutoplay(); };
|
|
340
|
+
/** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
|
|
341
|
+
playoutDelayMs = null;
|
|
342
|
+
/** devicesChanged: the device list last seen, kept while someone listens. */
|
|
343
|
+
knownDevices = [];
|
|
344
|
+
deviceWatchOn = false;
|
|
345
|
+
onDeviceChange = () => { void this.refreshDevices(); };
|
|
346
|
+
/** Videos adaptive stream has paused at the server, by producer id. */
|
|
347
|
+
adaptivePaused = new Set();
|
|
348
|
+
/** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
|
|
349
|
+
pauseUnsupported = false;
|
|
350
|
+
/** The last cpuConstrained said. */
|
|
351
|
+
cpuConstrainedNow = false;
|
|
352
|
+
/** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
|
|
353
|
+
performanceFirst = false;
|
|
354
|
+
/**
|
|
355
|
+
* Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
|
|
356
|
+
* watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
|
|
357
|
+
*/
|
|
358
|
+
layersNeeded = new Map();
|
|
359
|
+
/** The highest camera layer this client set active, and on which producer. */
|
|
360
|
+
cameraMaxLayer = null;
|
|
178
361
|
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
179
362
|
endRoomWaiters = new Set();
|
|
180
363
|
closedByUs = false;
|
|
@@ -201,7 +384,14 @@ class StreamClient {
|
|
|
201
384
|
codecFallbackLogged = false;
|
|
202
385
|
sendStatsBaseline = new Map();
|
|
203
386
|
recvStatsBaseline = new Map();
|
|
204
|
-
limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) =>
|
|
387
|
+
limitWatch = new stats_js_1.VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
|
|
388
|
+
this.emit('videoLimited', event);
|
|
389
|
+
const constrained = event.reason === 'cpu';
|
|
390
|
+
if (constrained !== this.cpuConstrainedNow) {
|
|
391
|
+
this.cpuConstrainedNow = constrained;
|
|
392
|
+
this.emit('cpuConstrained', { constrained });
|
|
393
|
+
}
|
|
394
|
+
}, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
205
395
|
/** Where the session stands; see connectionStateChanged. */
|
|
206
396
|
state = 'idle';
|
|
207
397
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
@@ -232,11 +422,55 @@ class StreamClient {
|
|
|
232
422
|
audioCapReported = false;
|
|
233
423
|
sizeCapReported = false;
|
|
234
424
|
networkWatch = new network_js_1.NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
425
|
+
/**
|
|
426
|
+
* A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
|
|
427
|
+
* token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
|
|
428
|
+
* measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
|
|
429
|
+
* real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
|
|
430
|
+
* TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
|
|
431
|
+
* seen. Never rejects: a failure is a step with `ok: false` and its error.
|
|
432
|
+
*/
|
|
433
|
+
static async preflight(wsUrl, token, options = {}) {
|
|
434
|
+
const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
|
|
435
|
+
const synthetic = options.media ? null : (0, preflight_js_1.syntheticMedia)();
|
|
436
|
+
const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
|
|
437
|
+
try {
|
|
438
|
+
return await (0, preflight_js_1.runPreflight)({
|
|
439
|
+
connect: () => client.connect(),
|
|
440
|
+
publish: (media) => client.publish(media),
|
|
441
|
+
sendState: () => client.sendTransport?.connectionState ?? null,
|
|
442
|
+
sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
|
|
443
|
+
onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
|
|
444
|
+
close: () => client.close(),
|
|
445
|
+
}, stream, options);
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
synthetic?.stop();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
|
|
453
|
+
* name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
|
|
454
|
+
* room (opening the room screen, say). Never rejects; it only saves time.
|
|
455
|
+
*/
|
|
456
|
+
static async prepareConnection(wsUrl) {
|
|
457
|
+
try {
|
|
458
|
+
const url = new URL(wsUrl);
|
|
459
|
+
url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
|
|
460
|
+
url.pathname = '/';
|
|
461
|
+
url.search = '';
|
|
462
|
+
await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Only a warm-up: connect() does the real work either way.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
235
468
|
constructor(wsUrl, token, options = {}) {
|
|
236
469
|
this.wsUrl = wsUrl;
|
|
237
470
|
this.options = options;
|
|
238
471
|
this.token = token;
|
|
239
472
|
this.tokenExpiry = (0, token_js_1.tokenExpiresAt)(token);
|
|
473
|
+
this.audioOnlyEnabled = options.audioOnlyFallback !== false;
|
|
240
474
|
}
|
|
241
475
|
on(event, fn) {
|
|
242
476
|
let set = this.listeners.get(event);
|
|
@@ -247,16 +481,28 @@ class StreamClient {
|
|
|
247
481
|
set.add(fn);
|
|
248
482
|
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
249
483
|
// they run only while heard.
|
|
250
|
-
if (event === 'videoLimited')
|
|
484
|
+
if (event === 'videoLimited' || event === 'cpuConstrained')
|
|
251
485
|
this.syncLimitWatch();
|
|
252
486
|
if (event === 'networkQuality')
|
|
253
487
|
this.syncNetworkWatch();
|
|
488
|
+
if (event === 'devicesChanged')
|
|
489
|
+
this.syncDeviceWatch();
|
|
490
|
+
if (event === 'audioLevels' || event === 'activeSpeakers')
|
|
491
|
+
this.syncLevelWatch();
|
|
492
|
+
if (event === 'mediaWarning')
|
|
493
|
+
this.syncHealthWatch();
|
|
254
494
|
return () => {
|
|
255
495
|
set.delete(fn);
|
|
256
|
-
if (event === 'videoLimited')
|
|
496
|
+
if (event === 'videoLimited' || event === 'cpuConstrained')
|
|
257
497
|
this.syncLimitWatch();
|
|
258
498
|
if (event === 'networkQuality')
|
|
259
499
|
this.syncNetworkWatch();
|
|
500
|
+
if (event === 'devicesChanged')
|
|
501
|
+
this.syncDeviceWatch();
|
|
502
|
+
if (event === 'audioLevels' || event === 'activeSpeakers')
|
|
503
|
+
this.syncLevelWatch();
|
|
504
|
+
if (event === 'mediaWarning')
|
|
505
|
+
this.syncHealthWatch();
|
|
260
506
|
};
|
|
261
507
|
}
|
|
262
508
|
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
@@ -270,6 +516,10 @@ class StreamClient {
|
|
|
270
516
|
this.state = next;
|
|
271
517
|
if (next === 'closed')
|
|
272
518
|
this.stopTimers();
|
|
519
|
+
else {
|
|
520
|
+
this.syncLevelWatch();
|
|
521
|
+
this.syncHealthWatch();
|
|
522
|
+
}
|
|
273
523
|
this.emit('connectionStateChanged', { state: next, previous });
|
|
274
524
|
}
|
|
275
525
|
/** Over for good: the state, then the event that says why. */
|
|
@@ -291,6 +541,20 @@ class StreamClient {
|
|
|
291
541
|
this.close();
|
|
292
542
|
}
|
|
293
543
|
stopTimers() {
|
|
544
|
+
if (this.publishProtectionTimer)
|
|
545
|
+
clearInterval(this.publishProtectionTimer);
|
|
546
|
+
this.publishProtectionTimer = null;
|
|
547
|
+
this.autoplayBlocked.clear();
|
|
548
|
+
this.watchGesture(false);
|
|
549
|
+
this.stopProtection();
|
|
550
|
+
this.stopLevelWatch();
|
|
551
|
+
this.stopHealthWatch(false);
|
|
552
|
+
this.stopDeviceWatch();
|
|
553
|
+
this.socketLiveness.stop();
|
|
554
|
+
this.mediaPaths.reset();
|
|
555
|
+
this.backoffWake?.();
|
|
556
|
+
this.onlineTarget?.removeEventListener('online', this.online);
|
|
557
|
+
this.onlineTarget = null;
|
|
294
558
|
if (this.tokenTimer)
|
|
295
559
|
clearTimeout(this.tokenTimer);
|
|
296
560
|
this.tokenTimer = null;
|
|
@@ -300,6 +564,7 @@ class StreamClient {
|
|
|
300
564
|
return this.listeners.get(event)?.size ?? 0;
|
|
301
565
|
}
|
|
302
566
|
emit(event, ...args) {
|
|
567
|
+
this.diagnosticsLog.record(event, args);
|
|
303
568
|
for (const fn of this.listeners.get(event) ?? []) {
|
|
304
569
|
try {
|
|
305
570
|
fn(...args);
|
|
@@ -337,7 +602,10 @@ class StreamClient {
|
|
|
337
602
|
// the socket would be closed by the server's join deadline instead, and
|
|
338
603
|
// that close would start reconnecting a session that never began.
|
|
339
604
|
const superseded = this.ws !== null && this.ws !== socket;
|
|
340
|
-
|
|
605
|
+
// A reconnect attempt that failed (its join timed out, say) lets its
|
|
606
|
+
// socket go now too, so nothing it says later reaches the session while
|
|
607
|
+
// the next attempt waits its backoff (V072-05).
|
|
608
|
+
if ((!this.joinedOnce || this.reconnecting) && !superseded) {
|
|
341
609
|
this.detachSocket();
|
|
342
610
|
if (this.state === 'connecting')
|
|
343
611
|
this.setState('idle');
|
|
@@ -352,6 +620,11 @@ class StreamClient {
|
|
|
352
620
|
this.joinedOnce = true;
|
|
353
621
|
this.skippedVideos.clear();
|
|
354
622
|
this.setState('connected');
|
|
623
|
+
this.socketLiveness.start();
|
|
624
|
+
if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
|
|
625
|
+
this.onlineTarget = window;
|
|
626
|
+
window.addEventListener('online', this.online);
|
|
627
|
+
}
|
|
355
628
|
this.scheduleTokenExpiring();
|
|
356
629
|
// A role or mute can change while the socket is away. The fresh roster
|
|
357
630
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
@@ -361,6 +634,8 @@ class StreamClient {
|
|
|
361
634
|
throw closedWhileConnecting();
|
|
362
635
|
}
|
|
363
636
|
const own = result.participants.find((p) => p.identity === result.identity);
|
|
637
|
+
this.moderate('audio', Boolean(own?.muted), true);
|
|
638
|
+
this.moderate('video', Boolean(own?.videoBlocked), true);
|
|
364
639
|
if (own?.muted)
|
|
365
640
|
this.applyLocalModeration('audio');
|
|
366
641
|
if (own?.videoBlocked)
|
|
@@ -381,12 +656,13 @@ class StreamClient {
|
|
|
381
656
|
this.remoteVideoLayers.delete(producerId);
|
|
382
657
|
// Subscribe to everything already publishing before we announce readiness.
|
|
383
658
|
// A close() part-way stops it: what is left would only fail, after closed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
659
|
+
// All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
|
|
660
|
+
// before its first await, in this order, and the receive transport is made once. A room with six publishers
|
|
661
|
+
// starts six videos together instead of one after another, two round trips each.
|
|
662
|
+
if (closed())
|
|
663
|
+
throw closedWhileConnecting();
|
|
664
|
+
await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
|
|
665
|
+
this.emit('error', err); })));
|
|
390
666
|
// Restore our own broadcast. This is what makes an origin failover
|
|
391
667
|
// survivable for the host: it reconnects onto the new origin and the
|
|
392
668
|
// stream comes back on its own.
|
|
@@ -397,6 +673,9 @@ class StreamClient {
|
|
|
397
673
|
if (closed())
|
|
398
674
|
throw closedWhileConnecting();
|
|
399
675
|
this.syncNetworkWatch();
|
|
676
|
+
this.syncProtection();
|
|
677
|
+
this.syncLevelWatch();
|
|
678
|
+
this.syncPublishProtection();
|
|
400
679
|
return result;
|
|
401
680
|
}
|
|
402
681
|
/**
|
|
@@ -416,22 +695,38 @@ class StreamClient {
|
|
|
416
695
|
return new Promise((resolve, reject) => {
|
|
417
696
|
const ws = new WebSocket((0, token_js_1.socketUrl)(this.wsUrl, this.token));
|
|
418
697
|
this.ws = ws;
|
|
698
|
+
// Bounded like every request (V072-05): a socket that does not open in
|
|
699
|
+
// time fails the attempt with `timeout` and is closed, instead of
|
|
700
|
+
// holding connect() -- or a reconnect attempt -- indefinitely.
|
|
701
|
+
this.openDeadline = setTimeout(() => {
|
|
702
|
+
reject(new errors_js_1.RpcError('timeout', `the connection did not open in ${RPC_TIMEOUT_MS / 1000} s`));
|
|
703
|
+
if (this.ws === ws)
|
|
704
|
+
this.detachSocket('open timed out', 'the connection did not open in time');
|
|
705
|
+
}, RPC_TIMEOUT_MS);
|
|
419
706
|
const onOpen = () => {
|
|
707
|
+
clearTimeout(this.openDeadline);
|
|
420
708
|
this.emit('connected');
|
|
421
709
|
resolve();
|
|
422
710
|
};
|
|
423
|
-
const onError = () =>
|
|
711
|
+
const onError = () => {
|
|
712
|
+
clearTimeout(this.openDeadline);
|
|
713
|
+
reject(new errors_js_1.RpcError('connect-failed', 'websocket failed to open'));
|
|
714
|
+
};
|
|
424
715
|
ws.addEventListener('open', onOpen, { once: true });
|
|
425
716
|
ws.addEventListener('error', onError, { once: true });
|
|
426
717
|
ws.addEventListener('message', (ev) => {
|
|
427
|
-
if (this.ws === ws)
|
|
718
|
+
if (this.ws === ws) {
|
|
719
|
+
this.socketLiveness.received();
|
|
428
720
|
this.onMessage(ev.data);
|
|
721
|
+
}
|
|
429
722
|
});
|
|
430
723
|
ws.addEventListener('close', (ev) => {
|
|
431
724
|
ws.removeEventListener('error', onError);
|
|
432
725
|
// Only the socket we currently consider ours may drive reconnection.
|
|
433
726
|
if (this.ws !== ws)
|
|
434
727
|
return;
|
|
728
|
+
this.socketLiveness.stop();
|
|
729
|
+
this.mediaPaths.reset();
|
|
435
730
|
this.ws = null;
|
|
436
731
|
this.failAllPending(new errors_js_1.RpcError('not-connected', `socket closed (${ev.code})`));
|
|
437
732
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
@@ -464,6 +759,8 @@ class StreamClient {
|
|
|
464
759
|
});
|
|
465
760
|
}
|
|
466
761
|
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
762
|
+
this.socketLiveness.stop();
|
|
763
|
+
clearTimeout(this.openDeadline);
|
|
467
764
|
const ws = this.ws;
|
|
468
765
|
this.ws = null;
|
|
469
766
|
if (!ws)
|
|
@@ -513,7 +810,7 @@ class StreamClient {
|
|
|
513
810
|
return;
|
|
514
811
|
}
|
|
515
812
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
516
|
-
await
|
|
813
|
+
await this.waitBackoff(backoff + Math.random() * 400);
|
|
517
814
|
if (this.closedByUs)
|
|
518
815
|
return;
|
|
519
816
|
try {
|
|
@@ -549,6 +846,9 @@ class StreamClient {
|
|
|
549
846
|
}
|
|
550
847
|
close() {
|
|
551
848
|
void this.stopRecording().catch(() => undefined);
|
|
849
|
+
// Adaptive stream stops watching elements: nothing is received any more.
|
|
850
|
+
this.adaptive.dispose();
|
|
851
|
+
this.adaptivePaused.clear();
|
|
552
852
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
553
853
|
// others have already reported themselves and set closedByUs.
|
|
554
854
|
const wasOpen = !this.closedByUs;
|
|
@@ -582,7 +882,15 @@ class StreamClient {
|
|
|
582
882
|
this.emit('closed', 'left');
|
|
583
883
|
}
|
|
584
884
|
teardownMedia() {
|
|
885
|
+
this.mediaPaths.reset();
|
|
886
|
+
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
887
|
+
// app's "poor connection" overlay does not outlive it.
|
|
888
|
+
this.clearFallbacks();
|
|
585
889
|
this.subscriptions.clear();
|
|
890
|
+
// Producers made after this are new: the server says again what their viewers need.
|
|
891
|
+
this.layersNeeded.clear();
|
|
892
|
+
// New consumers start paused at the server; adaptive stream decides again for each.
|
|
893
|
+
this.adaptivePaused.clear();
|
|
586
894
|
for (const c of this.consumers.values())
|
|
587
895
|
c.close();
|
|
588
896
|
this.consumers.clear();
|
|
@@ -802,6 +1110,35 @@ class StreamClient {
|
|
|
802
1110
|
case 'newProducer':
|
|
803
1111
|
await this.consume(data).catch((err) => this.emit('error', err));
|
|
804
1112
|
break;
|
|
1113
|
+
case 'streamFallback': {
|
|
1114
|
+
// The media server cut (or resumed) one of this viewer's videos for its bandwidth.
|
|
1115
|
+
const e = data;
|
|
1116
|
+
if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
|
|
1117
|
+
break;
|
|
1118
|
+
const cut = e.fallback === true;
|
|
1119
|
+
if (cut === this.bandwidthCut.has(e.producerId))
|
|
1120
|
+
break;
|
|
1121
|
+
if (cut)
|
|
1122
|
+
this.bandwidthCut.add(e.producerId);
|
|
1123
|
+
else
|
|
1124
|
+
this.bandwidthCut.delete(e.producerId);
|
|
1125
|
+
this.emitFallback(e.producerId, cut, 'bandwidth');
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
case 'producerLayers': {
|
|
1129
|
+
// Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
|
|
1130
|
+
// encoded (the smallest always is, so a new viewer starts at once); null means every layer.
|
|
1131
|
+
const e = data;
|
|
1132
|
+
if (typeof e.producerId !== 'string')
|
|
1133
|
+
break;
|
|
1134
|
+
if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
|
|
1135
|
+
this.layersNeeded.set(e.producerId, e.spatialLayer);
|
|
1136
|
+
else
|
|
1137
|
+
this.layersNeeded.delete(e.producerId);
|
|
1138
|
+
if (this.cameraProducer()?.id === e.producerId)
|
|
1139
|
+
void this.cameraTask(() => this.syncCameraLayers());
|
|
1140
|
+
break;
|
|
1141
|
+
}
|
|
805
1142
|
case 'producerClosed': {
|
|
806
1143
|
const { producerId, identity } = data;
|
|
807
1144
|
this.subscriptions.delete(producerId);
|
|
@@ -809,6 +1146,9 @@ class StreamClient {
|
|
|
809
1146
|
this.receiveQualities.delete(producerId);
|
|
810
1147
|
this.remoteVideoLayers.delete(producerId);
|
|
811
1148
|
this.skippedVideos.delete(producerId);
|
|
1149
|
+
this.adaptivePaused.delete(producerId);
|
|
1150
|
+
this.bandwidthCut.delete(producerId);
|
|
1151
|
+
this.protectionHeld.delete(producerId);
|
|
812
1152
|
const consumer = this.consumers.get(producerId);
|
|
813
1153
|
consumer?.close();
|
|
814
1154
|
this.consumers.delete(producerId);
|
|
@@ -845,6 +1185,8 @@ class StreamClient {
|
|
|
845
1185
|
case 'participantMuted': {
|
|
846
1186
|
const wire = data;
|
|
847
1187
|
const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
|
|
1188
|
+
if (e.identity && e.identity === this.joinResult?.identity)
|
|
1189
|
+
this.moderate(e.kind, e.paused, true);
|
|
848
1190
|
this.updateRoster((list) => list.map((p) => p.identity !== e.identity ? p : { ...p,
|
|
849
1191
|
...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
|
|
850
1192
|
...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
|
|
@@ -862,6 +1204,9 @@ class StreamClient {
|
|
|
862
1204
|
const e = data;
|
|
863
1205
|
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
864
1206
|
this.emit('participantLeft', e);
|
|
1207
|
+
const speakers = this.speakers.remove(e.identity);
|
|
1208
|
+
if (speakers)
|
|
1209
|
+
this.emit('activeSpeakers', { speakers });
|
|
865
1210
|
break;
|
|
866
1211
|
}
|
|
867
1212
|
case 'roleChanged': {
|
|
@@ -891,6 +1236,7 @@ class StreamClient {
|
|
|
891
1236
|
// in-app music: it travels as audio). Reflect it locally; a release
|
|
892
1237
|
// changes nothing here -- switching back on is the user's choice.
|
|
893
1238
|
const e = data;
|
|
1239
|
+
this.moderate(e.kind, Boolean(e.paused));
|
|
894
1240
|
if (e.paused) {
|
|
895
1241
|
this.applyLocalModeration(e.kind);
|
|
896
1242
|
this.emitLocalMedia();
|
|
@@ -1008,16 +1354,21 @@ class StreamClient {
|
|
|
1008
1354
|
return Promise.resolve(this.sendTransport);
|
|
1009
1355
|
if (this.sendTransportPending)
|
|
1010
1356
|
return this.sendTransportPending;
|
|
1011
|
-
|
|
1012
|
-
this.sendTransportPending
|
|
1357
|
+
const pending = this.createSendTransport().finally(() => {
|
|
1358
|
+
if (this.sendTransportPending === pending)
|
|
1359
|
+
this.sendTransportPending = null;
|
|
1013
1360
|
});
|
|
1014
|
-
return this.sendTransportPending;
|
|
1361
|
+
return this.sendTransportPending = pending;
|
|
1015
1362
|
}
|
|
1016
1363
|
async createSendTransport() {
|
|
1017
1364
|
if (!this.device)
|
|
1018
1365
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1366
|
+
const device = this.device;
|
|
1367
|
+
const socket = this.ws;
|
|
1019
1368
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
1020
|
-
|
|
1369
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1370
|
+
throw closedWhileConnecting();
|
|
1371
|
+
const transport = device.createSendTransport({
|
|
1021
1372
|
...params,
|
|
1022
1373
|
iceServers: this.joinResult?.iceServers,
|
|
1023
1374
|
});
|
|
@@ -1035,6 +1386,7 @@ class StreamClient {
|
|
|
1035
1386
|
.catch(fail);
|
|
1036
1387
|
});
|
|
1037
1388
|
this.sendTransport = transport;
|
|
1389
|
+
this.watchTransport(transport);
|
|
1038
1390
|
return transport;
|
|
1039
1391
|
}
|
|
1040
1392
|
ensureRecvTransport() {
|
|
@@ -1042,16 +1394,21 @@ class StreamClient {
|
|
|
1042
1394
|
return Promise.resolve(this.recvTransport);
|
|
1043
1395
|
if (this.recvTransportPending)
|
|
1044
1396
|
return this.recvTransportPending;
|
|
1045
|
-
|
|
1046
|
-
this.recvTransportPending
|
|
1397
|
+
const pending = this.createRecvTransport().finally(() => {
|
|
1398
|
+
if (this.recvTransportPending === pending)
|
|
1399
|
+
this.recvTransportPending = null;
|
|
1047
1400
|
});
|
|
1048
|
-
return this.recvTransportPending;
|
|
1401
|
+
return this.recvTransportPending = pending;
|
|
1049
1402
|
}
|
|
1050
1403
|
async createRecvTransport() {
|
|
1051
1404
|
if (!this.device)
|
|
1052
1405
|
throw new errors_js_1.RpcError('not-connected', 'not connected');
|
|
1406
|
+
const device = this.device;
|
|
1407
|
+
const socket = this.ws;
|
|
1053
1408
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1054
|
-
|
|
1409
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1410
|
+
throw closedWhileConnecting();
|
|
1411
|
+
const transport = device.createRecvTransport({
|
|
1055
1412
|
...params,
|
|
1056
1413
|
iceServers: this.joinResult?.iceServers,
|
|
1057
1414
|
});
|
|
@@ -1061,6 +1418,7 @@ class StreamClient {
|
|
|
1061
1418
|
.catch(fail);
|
|
1062
1419
|
});
|
|
1063
1420
|
this.recvTransport = transport;
|
|
1421
|
+
this.watchTransport(transport);
|
|
1064
1422
|
return transport;
|
|
1065
1423
|
}
|
|
1066
1424
|
/**
|
|
@@ -1094,10 +1452,28 @@ class StreamClient {
|
|
|
1094
1452
|
// leave nothing behind that a reconnect would publish again.
|
|
1095
1453
|
if (generation !== this.publishGeneration)
|
|
1096
1454
|
throw unpublishedWhile('stream');
|
|
1097
|
-
|
|
1455
|
+
// A second publish adds to the first (the microphone, then the camera):
|
|
1456
|
+
// one local stream carries every live track, so unpublish() and close()
|
|
1457
|
+
// stop them all and a reconnect publishes each again. Replacing it
|
|
1458
|
+
// orphaned the first stream's tracks, still capturing (V072-01).
|
|
1459
|
+
const previous = this.localStream;
|
|
1460
|
+
const merged = previous !== null && previous !== stream && previous.getTracks().some((t) => t.readyState === 'live');
|
|
1461
|
+
if (merged) {
|
|
1462
|
+
const ids = new Set(stream.getTracks().map((t) => t.id));
|
|
1463
|
+
// The new stream's tracks first: of one kind, the newer track is the one
|
|
1464
|
+
// published (as before), and the older one is still stopped with it.
|
|
1465
|
+
this.localStream = new MediaStream([
|
|
1466
|
+
...stream.getTracks(),
|
|
1467
|
+
...previous.getTracks().filter((t) => t.readyState === 'live' && !ids.has(t.id)),
|
|
1468
|
+
]);
|
|
1469
|
+
this.emit('localStreamChanged', this.localStream);
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
this.localStream = stream;
|
|
1473
|
+
}
|
|
1098
1474
|
this.wasPublishing = true;
|
|
1099
1475
|
if (constraints)
|
|
1100
|
-
this.publishConstraints = constraints;
|
|
1476
|
+
this.publishConstraints = merged ? mergeConstraints(this.publishConstraints, constraints) : constraints;
|
|
1101
1477
|
if (options) {
|
|
1102
1478
|
this.publishOptions = { ...options };
|
|
1103
1479
|
this.frameRateFallback = false;
|
|
@@ -1208,6 +1584,7 @@ class StreamClient {
|
|
|
1208
1584
|
appData: { source: 'mic', audioQuality: quality },
|
|
1209
1585
|
});
|
|
1210
1586
|
await this.keepProducer(producer, generation, 'microphone');
|
|
1587
|
+
this.watchTrackEnded(producer);
|
|
1211
1588
|
this.micQuality = quality;
|
|
1212
1589
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
1213
1590
|
await this.pauseOnServer(producer);
|
|
@@ -1354,6 +1731,7 @@ class StreamClient {
|
|
|
1354
1731
|
if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
|
|
1355
1732
|
previousSize?.width === width && previousSize?.height === height) {
|
|
1356
1733
|
await current.replaceTrack({ track });
|
|
1734
|
+
this.watchTrackEnded(current);
|
|
1357
1735
|
await this.applyLayerBitrates(current, next);
|
|
1358
1736
|
return;
|
|
1359
1737
|
}
|
|
@@ -1457,7 +1835,9 @@ class StreamClient {
|
|
|
1457
1835
|
this.receiveQualities.delete(producerId);
|
|
1458
1836
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1459
1837
|
this.preferredLayers.set(producerId, layers);
|
|
1460
|
-
|
|
1838
|
+
// Shown through attachVideo: the lower of this and the size it is shown at.
|
|
1839
|
+
const adaptive = this.adaptive.current(producerId);
|
|
1840
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1461
1841
|
}
|
|
1462
1842
|
async collectVideoStats(baseline) {
|
|
1463
1843
|
const producer = this.cameraProducer();
|
|
@@ -1496,7 +1876,8 @@ class StreamClient {
|
|
|
1496
1876
|
}
|
|
1497
1877
|
this.receiveQualities.set(producerId, quality);
|
|
1498
1878
|
this.preferredLayers.set(producerId, layers);
|
|
1499
|
-
|
|
1879
|
+
const adaptive = this.adaptive.current(producerId);
|
|
1880
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1500
1881
|
}
|
|
1501
1882
|
cameraProducer() {
|
|
1502
1883
|
for (const p of this.producers.values()) {
|
|
@@ -1588,14 +1969,65 @@ class StreamClient {
|
|
|
1588
1969
|
// unpublish() may have run while this was being made (a publish, a
|
|
1589
1970
|
// quality change or a track swap under way).
|
|
1590
1971
|
await this.keepProducer(producer, generation, 'camera');
|
|
1972
|
+
this.watchTrackEnded(producer);
|
|
1973
|
+
this.syncPublishProtection();
|
|
1591
1974
|
this.cameraEncodings = encodings;
|
|
1592
1975
|
this.frameRateLowSamples = 0;
|
|
1593
1976
|
this.frameRateWarmupSamples = 2;
|
|
1594
1977
|
await this.pauseOnServer(producer);
|
|
1595
1978
|
await this.applyDegradationPreference(producer);
|
|
1596
1979
|
this.syncLimitWatch();
|
|
1980
|
+
// A new camera producer starts with every layer: put the performance and viewer limits back on it.
|
|
1981
|
+
await this.syncCameraLayers();
|
|
1597
1982
|
return producer;
|
|
1598
1983
|
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Lighten this device's work while it is overloaded (LiveKit's
|
|
1986
|
+
* `prioritizePerformance`): the camera's largest simulcast layer stops being
|
|
1987
|
+
* encoded -- the most expensive part of publishing -- and viewers get the
|
|
1988
|
+
* next one. `false` brings it back. Call it on `cpuConstrained`, together
|
|
1989
|
+
* with your app's own savings (effects off, smaller remote videos). Kept for
|
|
1990
|
+
* later publishes and reconnects until changed. A camera sent as one layer
|
|
1991
|
+
* is left as it is.
|
|
1992
|
+
*/
|
|
1993
|
+
async prioritizePerformance(on = true) {
|
|
1994
|
+
this.performanceFirst = on;
|
|
1995
|
+
await this.cameraTask(() => this.syncCameraLayers());
|
|
1996
|
+
}
|
|
1997
|
+
/** Whether `prioritizePerformance()` is in force. */
|
|
1998
|
+
get performancePrioritized() {
|
|
1999
|
+
return this.performanceFirst;
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* The camera's active simulcast layers: every layer, except the top one while
|
|
2003
|
+
* performance comes first, and none above the highest any viewer needs
|
|
2004
|
+
* (dynacast; the smallest always stays on, so a new viewer starts at once).
|
|
2005
|
+
*/
|
|
2006
|
+
async syncCameraLayers() {
|
|
2007
|
+
const producer = this.cameraProducer();
|
|
2008
|
+
const count = this.cameraEncodings?.length ?? 0;
|
|
2009
|
+
if (!producer || producer.closed || count < 2)
|
|
2010
|
+
return;
|
|
2011
|
+
const top = count - 1;
|
|
2012
|
+
let max = top;
|
|
2013
|
+
if (this.performanceFirst)
|
|
2014
|
+
max = Math.min(max, top - 1);
|
|
2015
|
+
const needed = this.layersNeeded.get(producer.id);
|
|
2016
|
+
if (needed !== undefined)
|
|
2017
|
+
max = Math.min(max, Math.max(0, needed));
|
|
2018
|
+
// A producer just made sends every layer: nothing to ask of it until a limit applies.
|
|
2019
|
+
const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
|
|
2020
|
+
if (applied === max)
|
|
2021
|
+
return;
|
|
2022
|
+
try {
|
|
2023
|
+
await producer.setMaxSpatialLayer(max);
|
|
2024
|
+
this.cameraMaxLayer = { producerId: producer.id, layer: max };
|
|
2025
|
+
}
|
|
2026
|
+
catch (err) {
|
|
2027
|
+
if (!producer.closed)
|
|
2028
|
+
this.emit('error', err);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
1599
2031
|
/**
|
|
1600
2032
|
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
1601
2033
|
* made. Kept, it would publish again what was just unpublished, so it is
|
|
@@ -1670,7 +2102,7 @@ class StreamClient {
|
|
|
1670
2102
|
}
|
|
1671
2103
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1672
2104
|
syncLimitWatch() {
|
|
1673
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
2105
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
|
|
1674
2106
|
}
|
|
1675
2107
|
/**
|
|
1676
2108
|
* Re-establish this client's broadcast after a reconnect.
|
|
@@ -2130,6 +2562,8 @@ class StreamClient {
|
|
|
2130
2562
|
const consumer = await transport.consume(params);
|
|
2131
2563
|
current(consumer);
|
|
2132
2564
|
this.consumers.set(remote.producerId, consumer);
|
|
2565
|
+
if (this.playoutDelayMs !== null)
|
|
2566
|
+
this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2133
2567
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
2134
2568
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
2135
2569
|
// its first frame instead of starting at the full picture. Not getting
|
|
@@ -2145,13 +2579,27 @@ class StreamClient {
|
|
|
2145
2579
|
this.emit('error', new errors_js_1.RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
2146
2580
|
}
|
|
2147
2581
|
}
|
|
2582
|
+
// Adaptive stream: a video the app shows through attachVideo starts at the
|
|
2583
|
+
// size it is shown, and one nobody can see stays paused.
|
|
2584
|
+
const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
|
|
2585
|
+
if (adaptive)
|
|
2586
|
+
layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2587
|
+
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2588
|
+
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
2148
2589
|
if (layers) {
|
|
2149
2590
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
2150
2591
|
}
|
|
2151
2592
|
current(consumer);
|
|
2152
2593
|
// The server created it paused. Resume only now that our end exists,
|
|
2153
2594
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2154
|
-
|
|
2595
|
+
this.adaptivePaused.delete(remote.producerId);
|
|
2596
|
+
if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
|
|
2597
|
+
this.adaptivePaused.add(remote.producerId);
|
|
2598
|
+
else
|
|
2599
|
+
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2600
|
+
if (held && this.pauseUnsupported) {
|
|
2601
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
|
|
2602
|
+
}
|
|
2155
2603
|
current(consumer);
|
|
2156
2604
|
const kind = consumer.kind;
|
|
2157
2605
|
const source = remote.source ?? defaultSource(kind);
|
|
@@ -2164,6 +2612,681 @@ class StreamClient {
|
|
|
2164
2612
|
producerId: remote.producerId,
|
|
2165
2613
|
source,
|
|
2166
2614
|
});
|
|
2615
|
+
if (kind === 'video')
|
|
2616
|
+
this.adaptive.consumed(remote.producerId);
|
|
2617
|
+
if (held) {
|
|
2618
|
+
this.protectionHeld.add(remote.producerId);
|
|
2619
|
+
this.emitFallback(remote.producerId, true, 'audio-protection');
|
|
2620
|
+
}
|
|
2621
|
+
this.syncProtection();
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
|
|
2625
|
+
* stream with receiveWhenViewVisible): tell the SDK which element shows a
|
|
2626
|
+
* remote video. It then receives the smallest layer that fills that element
|
|
2627
|
+
* sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
|
|
2628
|
+
* video at the server while the element is not visible (scrolled away,
|
|
2629
|
+
* `display: none`, or the tab in the background), so a grid of small tiles
|
|
2630
|
+
* stops decoding full-size video. Attach the same video to several elements
|
|
2631
|
+
* and the largest visible one decides. Returns the function that detaches
|
|
2632
|
+
* it; detach when the element goes away. A ceiling set with
|
|
2633
|
+
* `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
|
|
2634
|
+
* server; one without `pauseConsumer` gets the smallest layer instead of a
|
|
2635
|
+
* pause.
|
|
2636
|
+
*/
|
|
2637
|
+
attachVideo(producerId, element) {
|
|
2638
|
+
return this.adaptive.attach(producerId, element);
|
|
2639
|
+
}
|
|
2640
|
+
/** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
|
|
2641
|
+
effectiveLayers(producerId, want) {
|
|
2642
|
+
let manual = this.preferredLayers.get(producerId);
|
|
2643
|
+
const quality = this.receiveQualities.get(producerId);
|
|
2644
|
+
if (quality && quality !== 'auto') {
|
|
2645
|
+
try {
|
|
2646
|
+
manual = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
2647
|
+
}
|
|
2648
|
+
catch {
|
|
2649
|
+
manual = undefined;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const adaptive = want?.visible ? want.spatialLayer : null;
|
|
2653
|
+
if (adaptive === null || adaptive === undefined)
|
|
2654
|
+
return manual ?? { spatialLayer: -1, temporalLayer: -1 };
|
|
2655
|
+
if (!manual || manual.spatialLayer < 0)
|
|
2656
|
+
return { spatialLayer: adaptive };
|
|
2657
|
+
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
2658
|
+
}
|
|
2659
|
+
// ------------------------------------------------------- diagnostics
|
|
2660
|
+
/**
|
|
2661
|
+
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
2662
|
+
* browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
|
|
2663
|
+
* the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
|
|
2664
|
+
* errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
|
|
2665
|
+
* no tokens and no media.
|
|
2666
|
+
*/
|
|
2667
|
+
async getDiagnostics() {
|
|
2668
|
+
const transport = async (t) => {
|
|
2669
|
+
if (!t || t.closed)
|
|
2670
|
+
return null;
|
|
2671
|
+
let path = null;
|
|
2672
|
+
try {
|
|
2673
|
+
path = (0, preflight_js_1.connectionPath)((await t.getStats()).values());
|
|
2674
|
+
}
|
|
2675
|
+
catch {
|
|
2676
|
+
// Best effort.
|
|
2677
|
+
}
|
|
2678
|
+
return { state: t.connectionState, path };
|
|
2679
|
+
};
|
|
2680
|
+
let network = null;
|
|
2681
|
+
try {
|
|
2682
|
+
const reports = await this.transportStats();
|
|
2683
|
+
const current = new Map([...reports].map(([key, report]) => [key, (0, network_js_1.readTransportStats)(report)]));
|
|
2684
|
+
if (current.size > 0) {
|
|
2685
|
+
const q = (0, network_js_1.networkQualityOf)(current, new Map(), this.producers.size > 0);
|
|
2686
|
+
network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
catch {
|
|
2690
|
+
// Best effort.
|
|
2691
|
+
}
|
|
2692
|
+
const join = this.joinResult;
|
|
2693
|
+
return {
|
|
2694
|
+
sdk: diagnostics_js_1.SDK_VERSION,
|
|
2695
|
+
at: Date.now(),
|
|
2696
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
2697
|
+
connectionState: this.state,
|
|
2698
|
+
room: join?.roomId ?? null,
|
|
2699
|
+
identity: join?.identity ?? null,
|
|
2700
|
+
role: join?.role ?? null,
|
|
2701
|
+
transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
|
|
2702
|
+
network,
|
|
2703
|
+
published: [...this.producers.values()].filter((p) => !p.closed)
|
|
2704
|
+
.map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
|
|
2705
|
+
received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
|
|
2706
|
+
identity: this.remoteInfo.get(producerId)?.identity ?? '',
|
|
2707
|
+
source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
|
|
2708
|
+
kind: c.kind,
|
|
2709
|
+
paused: c.paused || this.adaptivePaused.has(producerId),
|
|
2710
|
+
})),
|
|
2711
|
+
log: this.diagnosticsLog.list(),
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
// ------------------------------------------------------- autoplay
|
|
2715
|
+
/**
|
|
2716
|
+
* Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
|
|
2717
|
+
* autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
|
|
2718
|
+
* page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
|
|
2719
|
+
* every other refused element -- on the next tap, click or key press by itself.
|
|
2720
|
+
*/
|
|
2721
|
+
async play(element) {
|
|
2722
|
+
try {
|
|
2723
|
+
await element.play();
|
|
2724
|
+
this.autoplayBlocked.delete(element);
|
|
2725
|
+
return true;
|
|
2726
|
+
}
|
|
2727
|
+
catch (err) {
|
|
2728
|
+
if (err?.name !== 'NotAllowedError')
|
|
2729
|
+
throw err;
|
|
2730
|
+
this.autoplayBlocked.add(element);
|
|
2731
|
+
this.watchGesture(true);
|
|
2732
|
+
this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
|
|
2733
|
+
return false;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
watchGesture(on) {
|
|
2737
|
+
if (typeof document === 'undefined' || on === this.gestureWatchOn)
|
|
2738
|
+
return;
|
|
2739
|
+
this.gestureWatchOn = on;
|
|
2740
|
+
for (const type of ['pointerdown', 'keydown', 'touchend']) {
|
|
2741
|
+
if (on)
|
|
2742
|
+
document.addEventListener(type, this.onGesture, { capture: true });
|
|
2743
|
+
else
|
|
2744
|
+
document.removeEventListener(type, this.onGesture, { capture: true });
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
async retryAutoplay() {
|
|
2748
|
+
for (const element of [...this.autoplayBlocked]) {
|
|
2749
|
+
try {
|
|
2750
|
+
await element.play();
|
|
2751
|
+
this.autoplayBlocked.delete(element);
|
|
2752
|
+
}
|
|
2753
|
+
catch {
|
|
2754
|
+
// Still refused (or the element went away); the next gesture tries again.
|
|
2755
|
+
if (!element.isConnected)
|
|
2756
|
+
this.autoplayBlocked.delete(element);
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
if (this.autoplayBlocked.size === 0)
|
|
2760
|
+
this.watchGesture(false);
|
|
2761
|
+
}
|
|
2762
|
+
// ------------------------------------------------------- playout delay
|
|
2763
|
+
/**
|
|
2764
|
+
* Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
|
|
2765
|
+
* `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
|
|
2766
|
+
* stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
|
|
2767
|
+
* it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
|
|
2768
|
+
* their own; 0-4000 ms.
|
|
2769
|
+
*/
|
|
2770
|
+
setPlayoutDelay(ms) {
|
|
2771
|
+
this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
|
|
2772
|
+
for (const consumer of this.consumers.values())
|
|
2773
|
+
this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2774
|
+
}
|
|
2775
|
+
applyPlayoutDelay(receiver) {
|
|
2776
|
+
if (!receiver || !('jitterBufferTarget' in receiver))
|
|
2777
|
+
return;
|
|
2778
|
+
try {
|
|
2779
|
+
receiver.jitterBufferTarget = this.playoutDelayMs;
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
// Out of the browser's range: it keeps its own.
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
// ------------------------------------------------------- audio levels
|
|
2786
|
+
/** Levels are read only while someone listens and the session is connected. */
|
|
2787
|
+
syncLevelWatch() {
|
|
2788
|
+
const wanted = !this.closedByUs && this.state === 'connected' &&
|
|
2789
|
+
(this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
|
|
2790
|
+
if (wanted && !this.levelTimer) {
|
|
2791
|
+
this.levelTimer = setInterval(() => void this.sampleAudioLevels(), speakers_js_1.AUDIO_LEVEL_INTERVAL_MS);
|
|
2792
|
+
this.levelTimer.unref?.();
|
|
2793
|
+
}
|
|
2794
|
+
else if (!wanted) {
|
|
2795
|
+
this.stopLevelWatch();
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
stopLevelWatch() {
|
|
2799
|
+
if (this.levelTimer)
|
|
2800
|
+
clearInterval(this.levelTimer);
|
|
2801
|
+
this.levelTimer = null;
|
|
2802
|
+
this.speakers.reset();
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
|
|
2806
|
+
* statistics report needed), the own microphone from its sender's media-source statistics.
|
|
2807
|
+
*/
|
|
2808
|
+
async sampleAudioLevels() {
|
|
2809
|
+
if (this.levelSampling)
|
|
2810
|
+
return;
|
|
2811
|
+
this.levelSampling = true;
|
|
2812
|
+
try {
|
|
2813
|
+
const levels = [];
|
|
2814
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
2815
|
+
if (consumer.kind !== 'audio' || consumer.closed)
|
|
2816
|
+
continue;
|
|
2817
|
+
const info = this.remoteInfo.get(producerId);
|
|
2818
|
+
if (!info)
|
|
2819
|
+
continue;
|
|
2820
|
+
const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
|
|
2821
|
+
levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: false });
|
|
2822
|
+
}
|
|
2823
|
+
const mic = this.micProducer();
|
|
2824
|
+
const self = this.joinResult?.identity;
|
|
2825
|
+
if (mic && !mic.paused && self) {
|
|
2826
|
+
const level = await senderLevel(mic);
|
|
2827
|
+
levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > speakers_js_1.SPEAKING_LEVEL, local: true });
|
|
2828
|
+
}
|
|
2829
|
+
if (!this.levelTimer)
|
|
2830
|
+
return;
|
|
2831
|
+
if (this.listenerCount('audioLevels') > 0)
|
|
2832
|
+
this.emit('audioLevels', levels);
|
|
2833
|
+
const loudest = new Map();
|
|
2834
|
+
for (const l of levels)
|
|
2835
|
+
if (l.source === 'mic')
|
|
2836
|
+
loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
|
|
2837
|
+
const speakers = this.speakers.update(loudest);
|
|
2838
|
+
if (speakers)
|
|
2839
|
+
this.emit('activeSpeakers', { speakers });
|
|
2840
|
+
}
|
|
2841
|
+
catch {
|
|
2842
|
+
// Best effort; the next reading tries again.
|
|
2843
|
+
}
|
|
2844
|
+
finally {
|
|
2845
|
+
this.levelSampling = false;
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
// ------------------------------------------------------- media health
|
|
2849
|
+
/** The published devices are sampled only while someone listens and the session is connected. */
|
|
2850
|
+
syncHealthWatch() {
|
|
2851
|
+
const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
|
|
2852
|
+
if (wanted && !this.healthTimer) {
|
|
2853
|
+
this.healthTimer = setInterval(() => void this.sampleHealth(), health_js_1.HEALTH_SAMPLE_MS);
|
|
2854
|
+
this.healthTimer.unref?.();
|
|
2855
|
+
}
|
|
2856
|
+
else if (!wanted) {
|
|
2857
|
+
this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
/** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
|
|
2861
|
+
stopHealthWatch(report) {
|
|
2862
|
+
if (this.healthTimer)
|
|
2863
|
+
clearInterval(this.healthTimer);
|
|
2864
|
+
this.healthTimer = null;
|
|
2865
|
+
for (const timer of this.interruptions.values())
|
|
2866
|
+
clearTimeout(timer);
|
|
2867
|
+
this.interruptions.clear();
|
|
2868
|
+
if (report)
|
|
2869
|
+
this.health.clearAll();
|
|
2870
|
+
}
|
|
2871
|
+
async sampleHealth() {
|
|
2872
|
+
if (this.healthSampling)
|
|
2873
|
+
return;
|
|
2874
|
+
this.healthSampling = true;
|
|
2875
|
+
try {
|
|
2876
|
+
const mic = this.micProducer();
|
|
2877
|
+
const camera = this.cameraProducer();
|
|
2878
|
+
const micLevel = mic && !mic.paused ? (0, health_js_1.readMediaSource)((await mic.getStats()).values()).level : null;
|
|
2879
|
+
const cameraFps = camera && !camera.paused ? (0, health_js_1.readMediaSource)((await camera.getStats()).values()).fps : null;
|
|
2880
|
+
if (!this.healthTimer)
|
|
2881
|
+
return;
|
|
2882
|
+
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
2883
|
+
// An interrupted device that is no longer published never says it came back: that is over too.
|
|
2884
|
+
if (!mic)
|
|
2885
|
+
this.health.interrupted('mic-interrupted', false);
|
|
2886
|
+
if (!camera)
|
|
2887
|
+
this.health.interrupted('camera-interrupted', false);
|
|
2888
|
+
}
|
|
2889
|
+
catch {
|
|
2890
|
+
// Best effort; the next sample tries again.
|
|
2891
|
+
}
|
|
2892
|
+
finally {
|
|
2893
|
+
this.healthSampling = false;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
// ---------------------------------------------------------------- devices
|
|
2897
|
+
/**
|
|
2898
|
+
* The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
|
|
2899
|
+
* page has been granted camera or microphone access.
|
|
2900
|
+
*/
|
|
2901
|
+
listDevices(kind) {
|
|
2902
|
+
return (0, devices_js_1.enumerate)(kind);
|
|
2903
|
+
}
|
|
2904
|
+
/**
|
|
2905
|
+
* Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
|
|
2906
|
+
* captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
|
|
2907
|
+
* published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
|
|
2908
|
+
* replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
|
|
2909
|
+
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
|
|
2910
|
+
*
|
|
2911
|
+
* For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
|
|
2912
|
+
* processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
|
|
2913
|
+
* Rejects when that kind is not being published.
|
|
2914
|
+
*/
|
|
2915
|
+
async switchDevice(kind, deviceId) {
|
|
2916
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
2917
|
+
if (!md?.getUserMedia)
|
|
2918
|
+
throw new Error('switchDevice: this browser cannot capture media');
|
|
2919
|
+
if (kind === 'videoinput') {
|
|
2920
|
+
const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2921
|
+
if (!current)
|
|
2922
|
+
throw new Error('switchDevice: no camera is being published');
|
|
2923
|
+
if (!(0, devices_js_1.deviceIdOf)(current)) {
|
|
2924
|
+
throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
|
|
2925
|
+
}
|
|
2926
|
+
const video = (0, devices_js_1.constraintsFor)(this.publishConstraints?.video, deviceId);
|
|
2927
|
+
const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
|
|
2928
|
+
if (!track)
|
|
2929
|
+
throw new Error('switchDevice: the camera gave no video');
|
|
2930
|
+
try {
|
|
2931
|
+
await this.replaceVideoTrack(track);
|
|
2932
|
+
}
|
|
2933
|
+
catch (err) {
|
|
2934
|
+
track.stop();
|
|
2935
|
+
throw err;
|
|
2936
|
+
}
|
|
2937
|
+
if (current !== track)
|
|
2938
|
+
current.stop();
|
|
2939
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
await this.micTask(async () => {
|
|
2943
|
+
const producer = this.micProducer();
|
|
2944
|
+
if (!producer)
|
|
2945
|
+
throw new Error('switchDevice: no microphone is being published');
|
|
2946
|
+
const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, deviceId);
|
|
2947
|
+
const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
2948
|
+
if (!track)
|
|
2949
|
+
throw new Error('switchDevice: the microphone gave no audio');
|
|
2950
|
+
if (producer.closed || this.micProducer() !== producer) {
|
|
2951
|
+
track.stop();
|
|
2952
|
+
throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
2953
|
+
}
|
|
2954
|
+
await this.adoptAudioTrack(producer, track);
|
|
2955
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
2958
|
+
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
2959
|
+
async adoptAudioTrack(producer, track) {
|
|
2960
|
+
const previous = producer.track;
|
|
2961
|
+
await producer.replaceTrack({ track });
|
|
2962
|
+
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
2963
|
+
this.localStream = new MediaStream([track, ...video]);
|
|
2964
|
+
this.emit('localStreamChanged', this.localStream);
|
|
2965
|
+
if (previous && previous !== track)
|
|
2966
|
+
previous.stop();
|
|
2967
|
+
this.watchTrackEnded(producer);
|
|
2968
|
+
}
|
|
2969
|
+
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
2970
|
+
watchTrackEnded(producer) {
|
|
2971
|
+
const track = producer.track;
|
|
2972
|
+
if (!track || typeof track.addEventListener !== 'function')
|
|
2973
|
+
return;
|
|
2974
|
+
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
2975
|
+
// The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
|
|
2976
|
+
const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
|
|
2977
|
+
if (!which)
|
|
2978
|
+
return;
|
|
2979
|
+
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
2980
|
+
const current = () => !this.closedByUs && !producer.closed && producer.track === track;
|
|
2981
|
+
track.addEventListener('mute', () => {
|
|
2982
|
+
if (!current() || this.interruptions.has(which))
|
|
2983
|
+
return;
|
|
2984
|
+
this.interruptions.set(which, setTimeout(() => {
|
|
2985
|
+
this.interruptions.delete(which);
|
|
2986
|
+
if (current() && track.muted)
|
|
2987
|
+
this.health.interrupted(code, true);
|
|
2988
|
+
}, health_js_1.INTERRUPTED_AFTER_MS));
|
|
2989
|
+
});
|
|
2990
|
+
track.addEventListener('unmute', () => {
|
|
2991
|
+
const timer = this.interruptions.get(which);
|
|
2992
|
+
if (timer)
|
|
2993
|
+
clearTimeout(timer);
|
|
2994
|
+
this.interruptions.delete(which);
|
|
2995
|
+
if (current())
|
|
2996
|
+
this.health.interrupted(code, false);
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
|
|
3001
|
+
* keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
|
|
3002
|
+
* swapped for another camera on its own (Chime stops it the same way).
|
|
3003
|
+
*/
|
|
3004
|
+
async onTrackEnded(producer, track) {
|
|
3005
|
+
const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
|
|
3006
|
+
if (!live())
|
|
3007
|
+
return;
|
|
3008
|
+
const deviceId = (0, devices_js_1.deviceIdOf)(track);
|
|
3009
|
+
if (producer.appData.source === 'mic') {
|
|
3010
|
+
await this.micTask(async () => {
|
|
3011
|
+
if (!live())
|
|
3012
|
+
return;
|
|
3013
|
+
try {
|
|
3014
|
+
const md = navigator.mediaDevices;
|
|
3015
|
+
const audio = (0, devices_js_1.constraintsFor)(this.publishConstraints?.audio, null);
|
|
3016
|
+
const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
3017
|
+
if (!next)
|
|
3018
|
+
throw new Error('no microphone');
|
|
3019
|
+
if (!live()) {
|
|
3020
|
+
next.stop();
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
await this.adoptAudioTrack(producer, next);
|
|
3024
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
3025
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
|
|
3026
|
+
}
|
|
3027
|
+
catch {
|
|
3028
|
+
if (!live())
|
|
3029
|
+
return;
|
|
3030
|
+
await this.setMicEnabled(false).catch(() => undefined);
|
|
3031
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
|
|
3032
|
+
}
|
|
3033
|
+
});
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
if (producer.appData.source === 'camera') {
|
|
3037
|
+
await this.setCamEnabled(false).catch(() => undefined);
|
|
3038
|
+
if (!this.closedByUs)
|
|
3039
|
+
this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
/** devicesChanged is watched only while someone listens. */
|
|
3043
|
+
syncDeviceWatch() {
|
|
3044
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3045
|
+
const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
|
|
3046
|
+
if (wanted && !this.deviceWatchOn) {
|
|
3047
|
+
this.deviceWatchOn = true;
|
|
3048
|
+
md.addEventListener('devicechange', this.onDeviceChange);
|
|
3049
|
+
void (0, devices_js_1.enumerate)().then((list) => { if (this.deviceWatchOn)
|
|
3050
|
+
this.knownDevices = list; }).catch(() => undefined);
|
|
3051
|
+
}
|
|
3052
|
+
else if (!wanted) {
|
|
3053
|
+
this.stopDeviceWatch();
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
stopDeviceWatch() {
|
|
3057
|
+
if (!this.deviceWatchOn)
|
|
3058
|
+
return;
|
|
3059
|
+
this.deviceWatchOn = false;
|
|
3060
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
3061
|
+
}
|
|
3062
|
+
async refreshDevices() {
|
|
3063
|
+
let now;
|
|
3064
|
+
try {
|
|
3065
|
+
now = await (0, devices_js_1.enumerate)();
|
|
3066
|
+
}
|
|
3067
|
+
catch {
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
if (!this.deviceWatchOn)
|
|
3071
|
+
return;
|
|
3072
|
+
const { added, removed } = (0, devices_js_1.diffDevices)(this.knownDevices, now);
|
|
3073
|
+
this.knownDevices = now;
|
|
3074
|
+
if (added.length > 0 || removed.length > 0)
|
|
3075
|
+
this.emit('devicesChanged', { devices: now, added, removed });
|
|
3076
|
+
}
|
|
3077
|
+
/** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
|
|
3078
|
+
withFallback(producerId, want) {
|
|
3079
|
+
if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
|
|
3080
|
+
return { visible: false, spatialLayer: null };
|
|
3081
|
+
return want;
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
|
|
3085
|
+
* While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
|
|
3086
|
+
* videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
|
|
3087
|
+
* back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
|
|
3088
|
+
* by `streamFallback`. `false` brings any held video back now and stops watching.
|
|
3089
|
+
*/
|
|
3090
|
+
setAudioOnlyFallback(enabled) {
|
|
3091
|
+
this.audioOnlyEnabled = enabled;
|
|
3092
|
+
if (!enabled)
|
|
3093
|
+
this.protection.release();
|
|
3094
|
+
this.syncProtection();
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
|
|
3098
|
+
* while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
|
|
3099
|
+
* camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
|
|
3100
|
+
* longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
|
|
3101
|
+
* `false` brings a held camera back now.
|
|
3102
|
+
*/
|
|
3103
|
+
setPublishFallback(enabled) {
|
|
3104
|
+
this.publishFallbackEnabled = enabled;
|
|
3105
|
+
if (!enabled)
|
|
3106
|
+
this.publishProtection.release();
|
|
3107
|
+
this.syncPublishProtection();
|
|
3108
|
+
}
|
|
3109
|
+
syncPublishProtection() {
|
|
3110
|
+
const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
|
|
3111
|
+
this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
|
|
3112
|
+
if (wanted && !this.publishProtectionTimer) {
|
|
3113
|
+
this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
|
|
3114
|
+
this.publishProtectionTimer.unref?.();
|
|
3115
|
+
}
|
|
3116
|
+
else if (!wanted && this.publishProtectionTimer) {
|
|
3117
|
+
clearInterval(this.publishProtectionTimer);
|
|
3118
|
+
this.publishProtectionTimer = null;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
async samplePublishProtection() {
|
|
3122
|
+
const transport = this.sendTransport;
|
|
3123
|
+
if (this.publishProtectionSampling || !transport || transport.closed)
|
|
3124
|
+
return;
|
|
3125
|
+
this.publishProtectionSampling = true;
|
|
3126
|
+
try {
|
|
3127
|
+
const report = await transport.getStats();
|
|
3128
|
+
if (transport !== this.sendTransport || !this.publishProtectionTimer)
|
|
3129
|
+
return;
|
|
3130
|
+
this.publishProtection.feed((0, fallback_js_1.readOutboundAudio)(report.values()));
|
|
3131
|
+
}
|
|
3132
|
+
catch {
|
|
3133
|
+
// Best effort.
|
|
3134
|
+
}
|
|
3135
|
+
finally {
|
|
3136
|
+
this.publishProtectionSampling = false;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
/** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
|
|
3140
|
+
async onPublishProtection(active) {
|
|
3141
|
+
const camera = this.cameraProducer();
|
|
3142
|
+
if (active) {
|
|
3143
|
+
if (!camera || camera.paused) {
|
|
3144
|
+
this.syncPublishProtection();
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
this.cameraHeldByFallback = true;
|
|
3148
|
+
camera.pause();
|
|
3149
|
+
this.emit('publishFallback', { fallback: true });
|
|
3150
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
|
|
3151
|
+
this.emitLocalMedia();
|
|
3152
|
+
}
|
|
3153
|
+
else if (this.cameraHeldByFallback) {
|
|
3154
|
+
this.cameraHeldByFallback = false;
|
|
3155
|
+
if (camera && camera.paused && !this.forcedOff.video) {
|
|
3156
|
+
camera.resume();
|
|
3157
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
|
|
3158
|
+
this.emitLocalMedia();
|
|
3159
|
+
}
|
|
3160
|
+
this.emit('publishFallback', { fallback: false });
|
|
3161
|
+
}
|
|
3162
|
+
this.syncPublishProtection();
|
|
3163
|
+
}
|
|
3164
|
+
/** Whether the audio-only fallback is holding remote cameras back now. */
|
|
3165
|
+
get audioOnlyFallbackActive() {
|
|
3166
|
+
return this.protection.active;
|
|
3167
|
+
}
|
|
3168
|
+
/** The audio-only fallback decided: hold remote cameras back, or let them come back. */
|
|
3169
|
+
onAudioProtection(active) {
|
|
3170
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3171
|
+
if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
|
|
3172
|
+
continue;
|
|
3173
|
+
const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
|
|
3174
|
+
void this.applyAdaptive(producerId, want);
|
|
3175
|
+
if (active && !this.protectionHeld.has(producerId)) {
|
|
3176
|
+
this.protectionHeld.add(producerId);
|
|
3177
|
+
this.emitFallback(producerId, true, 'audio-protection');
|
|
3178
|
+
}
|
|
3179
|
+
else if (!active && this.protectionHeld.delete(producerId)) {
|
|
3180
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
this.syncProtection();
|
|
3184
|
+
}
|
|
3185
|
+
emitFallback(producerId, fallback, reason) {
|
|
3186
|
+
const info = this.remoteInfo.get(producerId);
|
|
3187
|
+
this.emit('streamFallback', {
|
|
3188
|
+
producerId,
|
|
3189
|
+
identity: info?.identity ?? '',
|
|
3190
|
+
source: this.remoteSources.get(producerId) ?? 'camera',
|
|
3191
|
+
fallback,
|
|
3192
|
+
reason,
|
|
3193
|
+
});
|
|
3194
|
+
}
|
|
3195
|
+
/** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
|
|
3196
|
+
clearFallbacks() {
|
|
3197
|
+
if (!this.closedByUs) {
|
|
3198
|
+
for (const producerId of this.bandwidthCut)
|
|
3199
|
+
this.emitFallback(producerId, false, 'bandwidth');
|
|
3200
|
+
for (const producerId of this.protectionHeld)
|
|
3201
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3202
|
+
}
|
|
3203
|
+
this.bandwidthCut.clear();
|
|
3204
|
+
this.protectionHeld.clear();
|
|
3205
|
+
this.protection.reset();
|
|
3206
|
+
this.stopProtection();
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
|
|
3210
|
+
* judge by and a remote camera to hold back (or one held back already).
|
|
3211
|
+
*/
|
|
3212
|
+
syncProtection() {
|
|
3213
|
+
let audio = false;
|
|
3214
|
+
let camera = false;
|
|
3215
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3216
|
+
if (consumer.closed)
|
|
3217
|
+
continue;
|
|
3218
|
+
if (consumer.kind === 'audio')
|
|
3219
|
+
audio = true;
|
|
3220
|
+
else if (this.remoteSources.get(producerId) === 'camera')
|
|
3221
|
+
camera = true;
|
|
3222
|
+
}
|
|
3223
|
+
const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
|
|
3224
|
+
if (wanted && !this.protectionTimer) {
|
|
3225
|
+
this.protectionTimer = setInterval(() => void this.sampleProtection(), fallback_js_1.PROTECT_SAMPLE_MS);
|
|
3226
|
+
// Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
|
|
3227
|
+
this.protectionTimer.unref?.();
|
|
3228
|
+
}
|
|
3229
|
+
else if (!wanted) {
|
|
3230
|
+
this.stopProtection();
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
stopProtection() {
|
|
3234
|
+
if (this.protectionTimer)
|
|
3235
|
+
clearInterval(this.protectionTimer);
|
|
3236
|
+
this.protectionTimer = null;
|
|
3237
|
+
}
|
|
3238
|
+
async sampleProtection() {
|
|
3239
|
+
const transport = this.recvTransport;
|
|
3240
|
+
if (this.protectionSampling || !transport || transport.closed)
|
|
3241
|
+
return;
|
|
3242
|
+
this.protectionSampling = true;
|
|
3243
|
+
try {
|
|
3244
|
+
const report = await transport.getStats();
|
|
3245
|
+
if (transport !== this.recvTransport || !this.protectionTimer)
|
|
3246
|
+
return;
|
|
3247
|
+
this.protection.feed((0, fallback_js_1.readInboundAudio)(report.values()));
|
|
3248
|
+
}
|
|
3249
|
+
catch {
|
|
3250
|
+
// Statistics are best effort; the next sample tries again.
|
|
3251
|
+
}
|
|
3252
|
+
finally {
|
|
3253
|
+
this.protectionSampling = false;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
/** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
|
|
3257
|
+
async applyAdaptive(producerId, want) {
|
|
3258
|
+
const consumer = this.consumers.get(producerId);
|
|
3259
|
+
// Not received (yet): a consumer made later starts from the decision in force.
|
|
3260
|
+
if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
|
|
3261
|
+
return;
|
|
3262
|
+
try {
|
|
3263
|
+
if (!want.visible) {
|
|
3264
|
+
if (this.pauseUnsupported) {
|
|
3265
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
if (this.adaptivePaused.has(producerId))
|
|
3269
|
+
return;
|
|
3270
|
+
this.adaptivePaused.add(producerId);
|
|
3271
|
+
await this.rpc('pauseConsumer', { consumerId: consumer.id });
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
if (this.adaptivePaused.delete(producerId))
|
|
3275
|
+
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
3276
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
|
|
3277
|
+
}
|
|
3278
|
+
catch (err) {
|
|
3279
|
+
if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
|
|
3280
|
+
return;
|
|
3281
|
+
if (!want.visible && err instanceof errors_js_1.RpcError && err.code === 'unknown-method') {
|
|
3282
|
+
// An older server: drop to the smallest layer instead of pausing.
|
|
3283
|
+
this.adaptivePaused.delete(producerId);
|
|
3284
|
+
this.pauseUnsupported = true;
|
|
3285
|
+
await this.applyAdaptive(producerId, want);
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
this.emit('error', err);
|
|
3289
|
+
}
|
|
2167
3290
|
}
|
|
2168
3291
|
// ------------------------------------------------------------- moderation
|
|
2169
3292
|
// ------------------------------------------------------------ local media
|
|
@@ -2211,21 +3334,63 @@ class StreamClient {
|
|
|
2211
3334
|
this.music?.setEnabled(false);
|
|
2212
3335
|
}
|
|
2213
3336
|
async setKindEnabled(kind, enabled) {
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
3337
|
+
// Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
|
|
3338
|
+
if (kind === 'video' && this.cameraHeldByFallback) {
|
|
3339
|
+
this.cameraHeldByFallback = false;
|
|
3340
|
+
this.publishProtection.reset();
|
|
3341
|
+
this.emit('publishFallback', { fallback: false });
|
|
3342
|
+
}
|
|
3343
|
+
const generation = this.publishGeneration;
|
|
3344
|
+
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
3345
|
+
// call resolves, as it does with nothing published (V072-03).
|
|
3346
|
+
const ended = () => generation !== this.publishGeneration || this.closedByUs;
|
|
3347
|
+
// The music has its own switch. Muting the microphone must not stop the song.
|
|
3348
|
+
const targets = [...this.producers.values()].filter((p) => p.kind === kind && p.appData.source === defaultSource(kind));
|
|
3349
|
+
for (const producer of targets) {
|
|
3350
|
+
if (ended())
|
|
3351
|
+
return;
|
|
3352
|
+
if (this.producers.get(producer.id) !== producer || producer.closed)
|
|
2217
3353
|
continue;
|
|
3354
|
+
const revision = this.moderationRevision[kind];
|
|
2218
3355
|
if (!enabled)
|
|
2219
3356
|
producer.pause();
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
3357
|
+
try {
|
|
3358
|
+
await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
|
|
3359
|
+
}
|
|
3360
|
+
catch (err) {
|
|
3361
|
+
if (ended())
|
|
3362
|
+
return;
|
|
3363
|
+
throw err;
|
|
3364
|
+
}
|
|
3365
|
+
if (ended())
|
|
3366
|
+
return;
|
|
3367
|
+
if (!enabled || this.producers.get(producer.id) !== producer || producer.closed)
|
|
3368
|
+
continue;
|
|
3369
|
+
if (revision !== this.moderationRevision[kind] || this.forcedOff[kind]) {
|
|
3370
|
+
// A moderator decided while this waited: the older request does not
|
|
3371
|
+
// undo it. It stays off here, and the server is told so too, since
|
|
3372
|
+
// which of the two it applied last cannot be known from here.
|
|
3373
|
+
void this.rpc('pauseProducer', { producerId: producer.id, paused: true }).catch(() => undefined);
|
|
3374
|
+
this.emitLocalMedia();
|
|
3375
|
+
throw new errors_js_1.RpcError('force-muted', `a moderator changed your ${kind} while this request waited; it stays off`);
|
|
3376
|
+
}
|
|
3377
|
+
producer.resume();
|
|
2223
3378
|
}
|
|
2224
3379
|
this.emitLocalMedia();
|
|
2225
3380
|
}
|
|
3381
|
+
/** Record a moderation decision ('' = both kinds). A roster snapshot counts only where it changes what is known. */
|
|
3382
|
+
moderate(kind, paused, snapshot = false) {
|
|
3383
|
+
for (const k of kind ? [kind] : ['audio', 'video']) {
|
|
3384
|
+
if (snapshot && this.forcedOff[k] === paused)
|
|
3385
|
+
continue;
|
|
3386
|
+
this.forcedOff[k] = paused;
|
|
3387
|
+
this.moderationRevision[k] += 1;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
2226
3390
|
/** Stop publishing entirely and release the local devices. */
|
|
2227
3391
|
async unpublish() {
|
|
2228
3392
|
void this.stopRecording().catch(error => this.emit('error', error));
|
|
3393
|
+
this.layersNeeded.clear();
|
|
2229
3394
|
// All of it takes effect now; only telling the server is waited for. So
|
|
2230
3395
|
// publish() and unpublish() act in the order they are called: a publish
|
|
2231
3396
|
// called while this still waits is not undone by it, and one already
|