@voltkit-rtc/client 0.7.3 → 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 +57 -0
- package/README.md +244 -2
- 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 +339 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1098 -26
- 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/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 +339 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1096 -26
- 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/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 +1097 -27
- package/src/link.ts +255 -0
- package/src/preflight.ts +191 -0
- package/src/speakers.ts +66 -0
package/dist/esm/index.js
CHANGED
|
@@ -9,7 +9,15 @@ import { CameraRecording } from './recording.js';
|
|
|
9
9
|
import { Device } from 'mediasoup-client';
|
|
10
10
|
import { isTerminalError, RpcError } from './errors.js';
|
|
11
11
|
import { above60fpsCap, AUDIO_SETTINGS, qualityAt, resolvePermissions, } from './media-permissions.js';
|
|
12
|
-
import { NetworkQualityWatch } from './network.js';
|
|
12
|
+
import { NetworkQualityWatch, networkQualityOf, readTransportStats } from './network.js';
|
|
13
|
+
import { MediaPathWatch, SocketLiveness } from './link.js';
|
|
14
|
+
import { AdaptiveVideos, pageIsVisible } from './adaptive.js';
|
|
15
|
+
import { AudioProtection, PROTECT_SAMPLE_MS, readInboundAudio, readOutboundAudio } from './fallback.js';
|
|
16
|
+
import { ActiveSpeakers, AUDIO_LEVEL_INTERVAL_MS, SPEAKING_LEVEL } from './speakers.js';
|
|
17
|
+
import { connectionPath, runPreflight, syntheticMedia } from './preflight.js';
|
|
18
|
+
import { DiagnosticsLog, SDK_VERSION } from './diagnostics.js';
|
|
19
|
+
import { HEALTH_SAMPLE_MS, HealthDecisions, INTERRUPTED_AFTER_MS, readMediaSource } from './health.js';
|
|
20
|
+
import { constraintsFor, deviceIdOf, diffDevices, enumerate } from './devices.js';
|
|
13
21
|
import { socketUrl, TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
14
22
|
import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
|
|
15
23
|
import { MusicSource } from './music.js';
|
|
@@ -24,19 +32,41 @@ export { AUDIO_QUALITIES, AUDIO_SETTINGS, DEFAULT_MEDIA_PERMISSIONS, resolvePerm
|
|
|
24
32
|
export { NETWORK_QUALITY_INTERVAL_MS, NETWORK_QUALITY_LEVELS, networkLevel } from './network.js';
|
|
25
33
|
export { TOKEN_EXPIRING_LEAD_MS, tokenExpiresAt } from './token.js';
|
|
26
34
|
export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
|
|
35
|
+
export { SPEAKING_LEVEL } from './speakers.js';
|
|
36
|
+
export { SDK_VERSION } from './diagnostics.js';
|
|
27
37
|
/** Where a session stands, in the order a session goes through them. `closed` is final; the `closed` event says why. */
|
|
28
38
|
export const CONNECTION_STATES = Object.freeze(['idle', 'connecting', 'connected', 'reconnecting', 'closed']);
|
|
29
39
|
const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
|
|
40
|
+
/** A receiver's most recent audio level (0..1), from its synchronization sources: cheap, made for this. */
|
|
41
|
+
function receiverLevel(receiver) {
|
|
42
|
+
const sources = receiver?.getSynchronizationSources?.() ?? [];
|
|
43
|
+
let level = 0;
|
|
44
|
+
for (const s of sources)
|
|
45
|
+
if (typeof s.audioLevel === 'number')
|
|
46
|
+
level = Math.max(level, s.audioLevel);
|
|
47
|
+
return level;
|
|
48
|
+
}
|
|
49
|
+
/** A microphone producer's level now (0..1), from its media-source statistics. */
|
|
50
|
+
async function senderLevel(producer) {
|
|
51
|
+
const stats = await producer.getStats();
|
|
52
|
+
for (const s of stats.values()) {
|
|
53
|
+
if (s.type === 'media-source' && typeof s.audioLevel === 'number')
|
|
54
|
+
return s.audioLevel;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
30
58
|
// Every event a StreamClient emits. Typed against ClientEvents, so adding an
|
|
31
59
|
// event without listing it here (or listing one that does not exist) fails to
|
|
32
60
|
// compile; the contract check compares this list with docs/sdk-contract.json.
|
|
33
61
|
const EVENT_NAMES = {
|
|
62
|
+
mediaReconnecting: true,
|
|
34
63
|
connected: true, disconnected: true, connectionStateChanged: true, joined: true,
|
|
35
64
|
track: true, trackEnded: true, trackMuted: true,
|
|
36
65
|
participantJoined: true, participantLeft: true, participantMuted: true, participantUpdated: true,
|
|
37
66
|
roleChanged: true, handRaised: true, forceMuted: true, claimChanged: true, roomMetadataChanged: true,
|
|
38
67
|
mustReconnect: true, roomClosed: true, kicked: true, streamInterrupted: true, closed: true,
|
|
39
|
-
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true,
|
|
68
|
+
usageLimit: true, walletLow: true, tokenExpiring: true, networkQuality: true, videoLimited: true, cpuConstrained: true,
|
|
69
|
+
streamFallback: true, publishFallback: true, devicesChanged: true, localTrackEnded: true, audioLevels: true, activeSpeakers: true, mediaWarning: true, autoplayFailed: true,
|
|
40
70
|
screenShareStarted: true, screenShareEnded: true, error: true, localMediaChanged: true, localStreamChanged: true,
|
|
41
71
|
message: true, messageDeleted: true, chatMuted: true, typing: true,
|
|
42
72
|
};
|
|
@@ -48,7 +78,6 @@ const MAX_RECONNECT_ATTEMPTS = 8;
|
|
|
48
78
|
const CLOSE_REPLACED = 4001;
|
|
49
79
|
/** The server ended the session on purpose and said why first: do not reconnect. */
|
|
50
80
|
const CLOSE_ENDED = 4003;
|
|
51
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
52
81
|
const isLayer = (value) => Number.isInteger(value) && value >= 0;
|
|
53
82
|
/**
|
|
54
83
|
* Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
|
|
@@ -91,6 +120,78 @@ function declareAudioQuality(rtpParameters, appData) {
|
|
|
91
120
|
export class StreamClient {
|
|
92
121
|
wsUrl;
|
|
93
122
|
options;
|
|
123
|
+
backoffWake = null;
|
|
124
|
+
onlineTarget = null;
|
|
125
|
+
online = () => {
|
|
126
|
+
if (this.closedByUs)
|
|
127
|
+
return;
|
|
128
|
+
this.backoffWake?.();
|
|
129
|
+
this.socketLiveness.checkNow();
|
|
130
|
+
};
|
|
131
|
+
socketLiveness = new SocketLiveness({
|
|
132
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
133
|
+
ping: () => { void this.rpc('ping').catch(() => undefined); },
|
|
134
|
+
dead: () => this.recoverConnection('socket-timeout'),
|
|
135
|
+
});
|
|
136
|
+
mediaPaths = new MediaPathWatch({
|
|
137
|
+
active: () => !this.closedByUs && this.state === 'connected',
|
|
138
|
+
restartIce: id => this.restartMediaIce(id),
|
|
139
|
+
reconnect: reason => this.recoverConnection(reason),
|
|
140
|
+
onPathChange: (transportId, reconnecting) => {
|
|
141
|
+
if (!this.closedByUs)
|
|
142
|
+
this.emit('mediaReconnecting', { transportId, reconnecting });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
recoverConnection(reason) {
|
|
146
|
+
if (this.closedByUs || !this.joinedOnce || this.state !== 'connected')
|
|
147
|
+
return;
|
|
148
|
+
this.setState('reconnecting');
|
|
149
|
+
if (this.closedByUs)
|
|
150
|
+
return; // a state listener may close synchronously
|
|
151
|
+
this.mediaPaths.reset();
|
|
152
|
+
if (this.closedByUs)
|
|
153
|
+
return;
|
|
154
|
+
this.detachSocket(reason);
|
|
155
|
+
this.networkWatch.set(false);
|
|
156
|
+
this.emit('disconnected', reason);
|
|
157
|
+
void this.scheduleReconnect();
|
|
158
|
+
}
|
|
159
|
+
async restartMediaIce(id) {
|
|
160
|
+
const transport = [this.sendTransport, this.recvTransport].find(t => t?.id === id);
|
|
161
|
+
const socket = this.ws;
|
|
162
|
+
if (!transport || transport.closed || this.closedByUs || this.state !== 'connected')
|
|
163
|
+
return 'skipped';
|
|
164
|
+
const { iceParameters } = await this.rpc('restartIce', { transportId: id });
|
|
165
|
+
if (this.ws !== socket || transport.closed || this.closedByUs ||
|
|
166
|
+
(transport !== this.sendTransport && transport !== this.recvTransport))
|
|
167
|
+
return 'skipped';
|
|
168
|
+
await transport.restartIce({ iceParameters });
|
|
169
|
+
if (this.ws === socket && !this.closedByUs && !transport.closed && transport.connectionState === 'connected') {
|
|
170
|
+
this.mediaPaths.update(id, 'connected');
|
|
171
|
+
}
|
|
172
|
+
return 'restarted';
|
|
173
|
+
}
|
|
174
|
+
watchTransport(transport) {
|
|
175
|
+
transport.on('connectionstatechange', state => {
|
|
176
|
+
if (transport !== this.sendTransport && transport !== this.recvTransport)
|
|
177
|
+
return;
|
|
178
|
+
if (!this.closedByUs && this.state === 'connected')
|
|
179
|
+
this.mediaPaths.update(transport.id, state);
|
|
180
|
+
});
|
|
181
|
+
transport.observer?.on('close', () => this.mediaPaths.forget(transport.id));
|
|
182
|
+
}
|
|
183
|
+
waitBackoff(ms) {
|
|
184
|
+
return new Promise(resolve => {
|
|
185
|
+
const finish = () => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
if (this.backoffWake === finish)
|
|
188
|
+
this.backoffWake = null;
|
|
189
|
+
resolve();
|
|
190
|
+
};
|
|
191
|
+
const timer = setTimeout(finish, ms);
|
|
192
|
+
this.backoffWake = finish;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
94
195
|
ws = null;
|
|
95
196
|
device = null;
|
|
96
197
|
sendTransport = null;
|
|
@@ -163,6 +264,67 @@ export class StreamClient {
|
|
|
163
264
|
* the full picture.
|
|
164
265
|
*/
|
|
165
266
|
preferredLayers = new Map();
|
|
267
|
+
/**
|
|
268
|
+
* Adaptive stream (attachVideo): each attached remote video at the size it is
|
|
269
|
+
* shown, and paused at the server while no attached element is visible.
|
|
270
|
+
*/
|
|
271
|
+
adaptive = new AdaptiveVideos({
|
|
272
|
+
layers: (id) => this.remoteVideoLayers.get(id) ?? [],
|
|
273
|
+
apply: (id, want) => { void this.applyAdaptive(id, this.withFallback(id, want)); },
|
|
274
|
+
pixelRatio: () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1),
|
|
275
|
+
pageVisible: pageIsVisible,
|
|
276
|
+
});
|
|
277
|
+
/** Audio-only fallback: whether it may happen, its decisions, and the sampling that feeds them. */
|
|
278
|
+
audioOnlyEnabled = true;
|
|
279
|
+
protection = new AudioProtection((active) => this.onAudioProtection(active));
|
|
280
|
+
protectionTimer = null;
|
|
281
|
+
protectionSampling = false;
|
|
282
|
+
/** Publisher-side fallback: off unless asked for; its decisions, timer, and whether it holds the camera now. */
|
|
283
|
+
publishFallbackEnabled = false;
|
|
284
|
+
publishProtection = new AudioProtection((active) => { void this.onPublishProtection(active); });
|
|
285
|
+
publishProtectionTimer = null;
|
|
286
|
+
publishProtectionSampling = false;
|
|
287
|
+
cameraHeldByFallback = false;
|
|
288
|
+
/** Remote videos the media server reports it cut for this viewer's bandwidth. */
|
|
289
|
+
bandwidthCut = new Set();
|
|
290
|
+
/** Remote cameras the audio-only fallback holds back now (reported with fallback: true). */
|
|
291
|
+
protectionHeld = new Set();
|
|
292
|
+
/** audioLevels / activeSpeakers: read only while someone listens. */
|
|
293
|
+
levelTimer = null;
|
|
294
|
+
levelSampling = false;
|
|
295
|
+
speakers = new ActiveSpeakers();
|
|
296
|
+
/** mediaWarning: decisions, the sampling timer, and the system interruptions being timed. */
|
|
297
|
+
health = new HealthDecisions((warning) => this.emit('mediaWarning', warning));
|
|
298
|
+
healthTimer = null;
|
|
299
|
+
healthSampling = false;
|
|
300
|
+
interruptions = new Map();
|
|
301
|
+
/** getDiagnostics(): the SDK's recent events, for a support ticket. */
|
|
302
|
+
diagnosticsLog = new DiagnosticsLog();
|
|
303
|
+
/** play(): elements the browser refused to play until the viewer interacts, retried on the next gesture. */
|
|
304
|
+
autoplayBlocked = new Set();
|
|
305
|
+
gestureWatchOn = false;
|
|
306
|
+
onGesture = () => { void this.retryAutoplay(); };
|
|
307
|
+
/** setPlayoutDelay: the receive buffer asked for every remote track, in ms (null: the browser's own). */
|
|
308
|
+
playoutDelayMs = null;
|
|
309
|
+
/** devicesChanged: the device list last seen, kept while someone listens. */
|
|
310
|
+
knownDevices = [];
|
|
311
|
+
deviceWatchOn = false;
|
|
312
|
+
onDeviceChange = () => { void this.refreshDevices(); };
|
|
313
|
+
/** Videos adaptive stream has paused at the server, by producer id. */
|
|
314
|
+
adaptivePaused = new Set();
|
|
315
|
+
/** The server has no pauseConsumer (an older controller): a hidden video drops to its smallest layer instead. */
|
|
316
|
+
pauseUnsupported = false;
|
|
317
|
+
/** The last cpuConstrained said. */
|
|
318
|
+
cpuConstrainedNow = false;
|
|
319
|
+
/** prioritizePerformance(): the camera's largest simulcast layer is not encoded while true. */
|
|
320
|
+
performanceFirst = false;
|
|
321
|
+
/**
|
|
322
|
+
* Dynacast (the server's `producerLayers`): the highest layer any viewer needs, by producer id (-1 = nobody
|
|
323
|
+
* watches, the smallest layer stays on). No entry: every layer, as before the server said anything.
|
|
324
|
+
*/
|
|
325
|
+
layersNeeded = new Map();
|
|
326
|
+
/** The highest camera layer this client set active, and on which producer. */
|
|
327
|
+
cameraMaxLayer = null;
|
|
166
328
|
/** endRoom() calls waiting for their reply; a roomClosed that arrives first answers them. */
|
|
167
329
|
endRoomWaiters = new Set();
|
|
168
330
|
closedByUs = false;
|
|
@@ -189,7 +351,14 @@ export class StreamClient {
|
|
|
189
351
|
codecFallbackLogged = false;
|
|
190
352
|
sendStatsBaseline = new Map();
|
|
191
353
|
recvStatsBaseline = new Map();
|
|
192
|
-
limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) =>
|
|
354
|
+
limitWatch = new VideoLimitWatch((baseline, isCurrent) => this.sampleCamera(baseline, isCurrent), (event) => {
|
|
355
|
+
this.emit('videoLimited', event);
|
|
356
|
+
const constrained = event.reason === 'cpu';
|
|
357
|
+
if (constrained !== this.cpuConstrainedNow) {
|
|
358
|
+
this.cpuConstrainedNow = constrained;
|
|
359
|
+
this.emit('cpuConstrained', { constrained });
|
|
360
|
+
}
|
|
361
|
+
}, undefined, () => { this.frameRateLowSamples = 0; this.frameRateWarmupSamples = 2; });
|
|
193
362
|
/** Where the session stands; see connectionStateChanged. */
|
|
194
363
|
state = 'idle';
|
|
195
364
|
/** When the token held for reconnects expires (unix ms), and the timer that warns before it does. */
|
|
@@ -220,11 +389,55 @@ export class StreamClient {
|
|
|
220
389
|
audioCapReported = false;
|
|
221
390
|
sizeCapReported = false;
|
|
222
391
|
networkWatch = new NetworkQualityWatch(() => this.transportStats(), () => this.producers.size > 0, (quality) => this.emit('networkQuality', quality));
|
|
392
|
+
/**
|
|
393
|
+
* A pre-call check (Twilio's runPreflight, LiveKit's ConnectionCheck, Agora's last-mile probe): joins the room the
|
|
394
|
+
* token names, publishes `options.media` (cloned, your tracks keep running) or a tone and a moving picture it makes,
|
|
395
|
+
* measures for `durationMs` (5 s) and leaves. Mint a host token for a test room nobody else is in: the session is
|
|
396
|
+
* real, and billed like any other for its few seconds. Resolves with each step, how media travelled (UDP is best;
|
|
397
|
+
* TCP or a relay means a firewall in the way), the round trip, loss and upload, and any camera or microphone warning
|
|
398
|
+
* seen. Never rejects: a failure is a step with `ok: false` and its error.
|
|
399
|
+
*/
|
|
400
|
+
static async preflight(wsUrl, token, options = {}) {
|
|
401
|
+
const client = new StreamClient(wsUrl, token, { audioOnlyFallback: false });
|
|
402
|
+
const synthetic = options.media ? null : syntheticMedia();
|
|
403
|
+
const stream = options.media ? new MediaStream(options.media.getTracks().map((t) => t.clone())) : synthetic.stream;
|
|
404
|
+
try {
|
|
405
|
+
return await runPreflight({
|
|
406
|
+
connect: () => client.connect(),
|
|
407
|
+
publish: (media) => client.publish(media),
|
|
408
|
+
sendState: () => client.sendTransport?.connectionState ?? null,
|
|
409
|
+
sendStats: async () => (client.sendTransport ? (await client.sendTransport.getStats()).values() : null),
|
|
410
|
+
onWarning: (listener) => client.on('mediaWarning', (w) => listener(w.code, w.active)),
|
|
411
|
+
close: () => client.close(),
|
|
412
|
+
}, stream, options);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
synthetic?.stop();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Warm the connection before `connect()` (LiveKit's prepareConnection): a HEAD request to the server, so the
|
|
420
|
+
* name lookup and the TLS handshake are done by the time the socket opens. Call it as the user heads for the
|
|
421
|
+
* room (opening the room screen, say). Never rejects; it only saves time.
|
|
422
|
+
*/
|
|
423
|
+
static async prepareConnection(wsUrl) {
|
|
424
|
+
try {
|
|
425
|
+
const url = new URL(wsUrl);
|
|
426
|
+
url.protocol = url.protocol === 'ws:' ? 'http:' : 'https:';
|
|
427
|
+
url.pathname = '/';
|
|
428
|
+
url.search = '';
|
|
429
|
+
await fetch(url.toString(), { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// Only a warm-up: connect() does the real work either way.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
223
435
|
constructor(wsUrl, token, options = {}) {
|
|
224
436
|
this.wsUrl = wsUrl;
|
|
225
437
|
this.options = options;
|
|
226
438
|
this.token = token;
|
|
227
439
|
this.tokenExpiry = tokenExpiresAt(token);
|
|
440
|
+
this.audioOnlyEnabled = options.audioOnlyFallback !== false;
|
|
228
441
|
}
|
|
229
442
|
on(event, fn) {
|
|
230
443
|
let set = this.listeners.get(event);
|
|
@@ -235,16 +448,28 @@ export class StreamClient {
|
|
|
235
448
|
set.add(fn);
|
|
236
449
|
// videoLimited and networkQuality cost a getStats every few seconds, so
|
|
237
450
|
// they run only while heard.
|
|
238
|
-
if (event === 'videoLimited')
|
|
451
|
+
if (event === 'videoLimited' || event === 'cpuConstrained')
|
|
239
452
|
this.syncLimitWatch();
|
|
240
453
|
if (event === 'networkQuality')
|
|
241
454
|
this.syncNetworkWatch();
|
|
455
|
+
if (event === 'devicesChanged')
|
|
456
|
+
this.syncDeviceWatch();
|
|
457
|
+
if (event === 'audioLevels' || event === 'activeSpeakers')
|
|
458
|
+
this.syncLevelWatch();
|
|
459
|
+
if (event === 'mediaWarning')
|
|
460
|
+
this.syncHealthWatch();
|
|
242
461
|
return () => {
|
|
243
462
|
set.delete(fn);
|
|
244
|
-
if (event === 'videoLimited')
|
|
463
|
+
if (event === 'videoLimited' || event === 'cpuConstrained')
|
|
245
464
|
this.syncLimitWatch();
|
|
246
465
|
if (event === 'networkQuality')
|
|
247
466
|
this.syncNetworkWatch();
|
|
467
|
+
if (event === 'devicesChanged')
|
|
468
|
+
this.syncDeviceWatch();
|
|
469
|
+
if (event === 'audioLevels' || event === 'activeSpeakers')
|
|
470
|
+
this.syncLevelWatch();
|
|
471
|
+
if (event === 'mediaWarning')
|
|
472
|
+
this.syncHealthWatch();
|
|
248
473
|
};
|
|
249
474
|
}
|
|
250
475
|
/** Where the session stands: idle, connecting, connected, reconnecting or closed. */
|
|
@@ -258,6 +483,10 @@ export class StreamClient {
|
|
|
258
483
|
this.state = next;
|
|
259
484
|
if (next === 'closed')
|
|
260
485
|
this.stopTimers();
|
|
486
|
+
else {
|
|
487
|
+
this.syncLevelWatch();
|
|
488
|
+
this.syncHealthWatch();
|
|
489
|
+
}
|
|
261
490
|
this.emit('connectionStateChanged', { state: next, previous });
|
|
262
491
|
}
|
|
263
492
|
/** Over for good: the state, then the event that says why. */
|
|
@@ -279,6 +508,20 @@ export class StreamClient {
|
|
|
279
508
|
this.close();
|
|
280
509
|
}
|
|
281
510
|
stopTimers() {
|
|
511
|
+
if (this.publishProtectionTimer)
|
|
512
|
+
clearInterval(this.publishProtectionTimer);
|
|
513
|
+
this.publishProtectionTimer = null;
|
|
514
|
+
this.autoplayBlocked.clear();
|
|
515
|
+
this.watchGesture(false);
|
|
516
|
+
this.stopProtection();
|
|
517
|
+
this.stopLevelWatch();
|
|
518
|
+
this.stopHealthWatch(false);
|
|
519
|
+
this.stopDeviceWatch();
|
|
520
|
+
this.socketLiveness.stop();
|
|
521
|
+
this.mediaPaths.reset();
|
|
522
|
+
this.backoffWake?.();
|
|
523
|
+
this.onlineTarget?.removeEventListener('online', this.online);
|
|
524
|
+
this.onlineTarget = null;
|
|
282
525
|
if (this.tokenTimer)
|
|
283
526
|
clearTimeout(this.tokenTimer);
|
|
284
527
|
this.tokenTimer = null;
|
|
@@ -288,6 +531,7 @@ export class StreamClient {
|
|
|
288
531
|
return this.listeners.get(event)?.size ?? 0;
|
|
289
532
|
}
|
|
290
533
|
emit(event, ...args) {
|
|
534
|
+
this.diagnosticsLog.record(event, args);
|
|
291
535
|
for (const fn of this.listeners.get(event) ?? []) {
|
|
292
536
|
try {
|
|
293
537
|
fn(...args);
|
|
@@ -343,6 +587,11 @@ export class StreamClient {
|
|
|
343
587
|
this.joinedOnce = true;
|
|
344
588
|
this.skippedVideos.clear();
|
|
345
589
|
this.setState('connected');
|
|
590
|
+
this.socketLiveness.start();
|
|
591
|
+
if (!this.onlineTarget && typeof window !== 'undefined' && window.addEventListener) {
|
|
592
|
+
this.onlineTarget = window;
|
|
593
|
+
window.addEventListener('online', this.online);
|
|
594
|
+
}
|
|
346
595
|
this.scheduleTokenExpiring();
|
|
347
596
|
// A role or mute can change while the socket is away. The fresh roster
|
|
348
597
|
// decides what may return; reconnecting never turns a blocked device on.
|
|
@@ -374,12 +623,13 @@ export class StreamClient {
|
|
|
374
623
|
this.remoteVideoLayers.delete(producerId);
|
|
375
624
|
// Subscribe to everything already publishing before we announce readiness.
|
|
376
625
|
// A close() part-way stops it: what is left would only fail, after closed.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
626
|
+
// All at once, as newProducer events already are (and the Flutter SDK does): each subscription reserves its slot
|
|
627
|
+
// before its first await, in this order, and the receive transport is made once. A room with six publishers
|
|
628
|
+
// starts six videos together instead of one after another, two round trips each.
|
|
629
|
+
if (closed())
|
|
630
|
+
throw closedWhileConnecting();
|
|
631
|
+
await Promise.all(result.producers.map((p) => this.consume(p).catch((err) => { if (!closed())
|
|
632
|
+
this.emit('error', err); })));
|
|
383
633
|
// Restore our own broadcast. This is what makes an origin failover
|
|
384
634
|
// survivable for the host: it reconnects onto the new origin and the
|
|
385
635
|
// stream comes back on its own.
|
|
@@ -390,6 +640,9 @@ export class StreamClient {
|
|
|
390
640
|
if (closed())
|
|
391
641
|
throw closedWhileConnecting();
|
|
392
642
|
this.syncNetworkWatch();
|
|
643
|
+
this.syncProtection();
|
|
644
|
+
this.syncLevelWatch();
|
|
645
|
+
this.syncPublishProtection();
|
|
393
646
|
return result;
|
|
394
647
|
}
|
|
395
648
|
/**
|
|
@@ -429,14 +682,18 @@ export class StreamClient {
|
|
|
429
682
|
ws.addEventListener('open', onOpen, { once: true });
|
|
430
683
|
ws.addEventListener('error', onError, { once: true });
|
|
431
684
|
ws.addEventListener('message', (ev) => {
|
|
432
|
-
if (this.ws === ws)
|
|
685
|
+
if (this.ws === ws) {
|
|
686
|
+
this.socketLiveness.received();
|
|
433
687
|
this.onMessage(ev.data);
|
|
688
|
+
}
|
|
434
689
|
});
|
|
435
690
|
ws.addEventListener('close', (ev) => {
|
|
436
691
|
ws.removeEventListener('error', onError);
|
|
437
692
|
// Only the socket we currently consider ours may drive reconnection.
|
|
438
693
|
if (this.ws !== ws)
|
|
439
694
|
return;
|
|
695
|
+
this.socketLiveness.stop();
|
|
696
|
+
this.mediaPaths.reset();
|
|
440
697
|
this.ws = null;
|
|
441
698
|
this.failAllPending(new RpcError('not-connected', `socket closed (${ev.code})`));
|
|
442
699
|
this.emit('disconnected', ev.reason || String(ev.code));
|
|
@@ -469,6 +726,7 @@ export class StreamClient {
|
|
|
469
726
|
});
|
|
470
727
|
}
|
|
471
728
|
detachSocket(reason = 'superseded', why = 'the connection was replaced') {
|
|
729
|
+
this.socketLiveness.stop();
|
|
472
730
|
clearTimeout(this.openDeadline);
|
|
473
731
|
const ws = this.ws;
|
|
474
732
|
this.ws = null;
|
|
@@ -519,7 +777,7 @@ export class StreamClient {
|
|
|
519
777
|
return;
|
|
520
778
|
}
|
|
521
779
|
const backoff = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15_000);
|
|
522
|
-
await
|
|
780
|
+
await this.waitBackoff(backoff + Math.random() * 400);
|
|
523
781
|
if (this.closedByUs)
|
|
524
782
|
return;
|
|
525
783
|
try {
|
|
@@ -555,6 +813,9 @@ export class StreamClient {
|
|
|
555
813
|
}
|
|
556
814
|
close() {
|
|
557
815
|
void this.stopRecording().catch(() => undefined);
|
|
816
|
+
// Adaptive stream stops watching elements: nothing is received any more.
|
|
817
|
+
this.adaptive.dispose();
|
|
818
|
+
this.adaptivePaused.clear();
|
|
558
819
|
// Only an app closing a live session is 'left'; kicked, room-closed and the
|
|
559
820
|
// others have already reported themselves and set closedByUs.
|
|
560
821
|
const wasOpen = !this.closedByUs;
|
|
@@ -588,7 +849,15 @@ export class StreamClient {
|
|
|
588
849
|
this.emit('closed', 'left');
|
|
589
850
|
}
|
|
590
851
|
teardownMedia() {
|
|
852
|
+
this.mediaPaths.reset();
|
|
853
|
+
// The videos go with their consumers; a reconnect decides afresh. What was reported held back is withdrawn, so an
|
|
854
|
+
// app's "poor connection" overlay does not outlive it.
|
|
855
|
+
this.clearFallbacks();
|
|
591
856
|
this.subscriptions.clear();
|
|
857
|
+
// Producers made after this are new: the server says again what their viewers need.
|
|
858
|
+
this.layersNeeded.clear();
|
|
859
|
+
// New consumers start paused at the server; adaptive stream decides again for each.
|
|
860
|
+
this.adaptivePaused.clear();
|
|
592
861
|
for (const c of this.consumers.values())
|
|
593
862
|
c.close();
|
|
594
863
|
this.consumers.clear();
|
|
@@ -808,6 +1077,35 @@ export class StreamClient {
|
|
|
808
1077
|
case 'newProducer':
|
|
809
1078
|
await this.consume(data).catch((err) => this.emit('error', err));
|
|
810
1079
|
break;
|
|
1080
|
+
case 'streamFallback': {
|
|
1081
|
+
// The media server cut (or resumed) one of this viewer's videos for its bandwidth.
|
|
1082
|
+
const e = data;
|
|
1083
|
+
if (typeof e.producerId !== 'string' || !this.consumers.has(e.producerId))
|
|
1084
|
+
break;
|
|
1085
|
+
const cut = e.fallback === true;
|
|
1086
|
+
if (cut === this.bandwidthCut.has(e.producerId))
|
|
1087
|
+
break;
|
|
1088
|
+
if (cut)
|
|
1089
|
+
this.bandwidthCut.add(e.producerId);
|
|
1090
|
+
else
|
|
1091
|
+
this.bandwidthCut.delete(e.producerId);
|
|
1092
|
+
this.emitFallback(e.producerId, cut, 'bandwidth');
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
case 'producerLayers': {
|
|
1096
|
+
// Dynacast: what this client's viewers need from one of its producers. Layers nobody receives are not
|
|
1097
|
+
// encoded (the smallest always is, so a new viewer starts at once); null means every layer.
|
|
1098
|
+
const e = data;
|
|
1099
|
+
if (typeof e.producerId !== 'string')
|
|
1100
|
+
break;
|
|
1101
|
+
if (typeof e.spatialLayer === 'number' && Number.isInteger(e.spatialLayer))
|
|
1102
|
+
this.layersNeeded.set(e.producerId, e.spatialLayer);
|
|
1103
|
+
else
|
|
1104
|
+
this.layersNeeded.delete(e.producerId);
|
|
1105
|
+
if (this.cameraProducer()?.id === e.producerId)
|
|
1106
|
+
void this.cameraTask(() => this.syncCameraLayers());
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
811
1109
|
case 'producerClosed': {
|
|
812
1110
|
const { producerId, identity } = data;
|
|
813
1111
|
this.subscriptions.delete(producerId);
|
|
@@ -815,6 +1113,9 @@ export class StreamClient {
|
|
|
815
1113
|
this.receiveQualities.delete(producerId);
|
|
816
1114
|
this.remoteVideoLayers.delete(producerId);
|
|
817
1115
|
this.skippedVideos.delete(producerId);
|
|
1116
|
+
this.adaptivePaused.delete(producerId);
|
|
1117
|
+
this.bandwidthCut.delete(producerId);
|
|
1118
|
+
this.protectionHeld.delete(producerId);
|
|
818
1119
|
const consumer = this.consumers.get(producerId);
|
|
819
1120
|
consumer?.close();
|
|
820
1121
|
this.consumers.delete(producerId);
|
|
@@ -870,6 +1171,9 @@ export class StreamClient {
|
|
|
870
1171
|
const e = data;
|
|
871
1172
|
this.updateRoster((list) => list.filter((p) => p.identity !== e.identity));
|
|
872
1173
|
this.emit('participantLeft', e);
|
|
1174
|
+
const speakers = this.speakers.remove(e.identity);
|
|
1175
|
+
if (speakers)
|
|
1176
|
+
this.emit('activeSpeakers', { speakers });
|
|
873
1177
|
break;
|
|
874
1178
|
}
|
|
875
1179
|
case 'roleChanged': {
|
|
@@ -1017,16 +1321,21 @@ export class StreamClient {
|
|
|
1017
1321
|
return Promise.resolve(this.sendTransport);
|
|
1018
1322
|
if (this.sendTransportPending)
|
|
1019
1323
|
return this.sendTransportPending;
|
|
1020
|
-
|
|
1021
|
-
this.sendTransportPending
|
|
1324
|
+
const pending = this.createSendTransport().finally(() => {
|
|
1325
|
+
if (this.sendTransportPending === pending)
|
|
1326
|
+
this.sendTransportPending = null;
|
|
1022
1327
|
});
|
|
1023
|
-
return this.sendTransportPending;
|
|
1328
|
+
return this.sendTransportPending = pending;
|
|
1024
1329
|
}
|
|
1025
1330
|
async createSendTransport() {
|
|
1026
1331
|
if (!this.device)
|
|
1027
1332
|
throw new RpcError('not-connected', 'not connected');
|
|
1333
|
+
const device = this.device;
|
|
1334
|
+
const socket = this.ws;
|
|
1028
1335
|
const params = (await this.rpc('createTransport', { direction: 'send' }));
|
|
1029
|
-
|
|
1336
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1337
|
+
throw closedWhileConnecting();
|
|
1338
|
+
const transport = device.createSendTransport({
|
|
1030
1339
|
...params,
|
|
1031
1340
|
iceServers: this.joinResult?.iceServers,
|
|
1032
1341
|
});
|
|
@@ -1044,6 +1353,7 @@ export class StreamClient {
|
|
|
1044
1353
|
.catch(fail);
|
|
1045
1354
|
});
|
|
1046
1355
|
this.sendTransport = transport;
|
|
1356
|
+
this.watchTransport(transport);
|
|
1047
1357
|
return transport;
|
|
1048
1358
|
}
|
|
1049
1359
|
ensureRecvTransport() {
|
|
@@ -1051,16 +1361,21 @@ export class StreamClient {
|
|
|
1051
1361
|
return Promise.resolve(this.recvTransport);
|
|
1052
1362
|
if (this.recvTransportPending)
|
|
1053
1363
|
return this.recvTransportPending;
|
|
1054
|
-
|
|
1055
|
-
this.recvTransportPending
|
|
1364
|
+
const pending = this.createRecvTransport().finally(() => {
|
|
1365
|
+
if (this.recvTransportPending === pending)
|
|
1366
|
+
this.recvTransportPending = null;
|
|
1056
1367
|
});
|
|
1057
|
-
return this.recvTransportPending;
|
|
1368
|
+
return this.recvTransportPending = pending;
|
|
1058
1369
|
}
|
|
1059
1370
|
async createRecvTransport() {
|
|
1060
1371
|
if (!this.device)
|
|
1061
1372
|
throw new RpcError('not-connected', 'not connected');
|
|
1373
|
+
const device = this.device;
|
|
1374
|
+
const socket = this.ws;
|
|
1062
1375
|
const params = (await this.rpc('createTransport', { direction: 'recv' }));
|
|
1063
|
-
|
|
1376
|
+
if (this.closedByUs || device !== this.device || socket !== this.ws)
|
|
1377
|
+
throw closedWhileConnecting();
|
|
1378
|
+
const transport = device.createRecvTransport({
|
|
1064
1379
|
...params,
|
|
1065
1380
|
iceServers: this.joinResult?.iceServers,
|
|
1066
1381
|
});
|
|
@@ -1070,6 +1385,7 @@ export class StreamClient {
|
|
|
1070
1385
|
.catch(fail);
|
|
1071
1386
|
});
|
|
1072
1387
|
this.recvTransport = transport;
|
|
1388
|
+
this.watchTransport(transport);
|
|
1073
1389
|
return transport;
|
|
1074
1390
|
}
|
|
1075
1391
|
/**
|
|
@@ -1235,6 +1551,7 @@ export class StreamClient {
|
|
|
1235
1551
|
appData: { source: 'mic', audioQuality: quality },
|
|
1236
1552
|
});
|
|
1237
1553
|
await this.keepProducer(producer, generation, 'microphone');
|
|
1554
|
+
this.watchTrackEnded(producer);
|
|
1238
1555
|
this.micQuality = quality;
|
|
1239
1556
|
// A microphone handed over switched off, or muted before a reconnect.
|
|
1240
1557
|
await this.pauseOnServer(producer);
|
|
@@ -1381,6 +1698,7 @@ export class StreamClient {
|
|
|
1381
1698
|
if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
|
|
1382
1699
|
previousSize?.width === width && previousSize?.height === height) {
|
|
1383
1700
|
await current.replaceTrack({ track });
|
|
1701
|
+
this.watchTrackEnded(current);
|
|
1384
1702
|
await this.applyLayerBitrates(current, next);
|
|
1385
1703
|
return;
|
|
1386
1704
|
}
|
|
@@ -1484,7 +1802,9 @@ export class StreamClient {
|
|
|
1484
1802
|
this.receiveQualities.delete(producerId);
|
|
1485
1803
|
// Kept first: a reconnect that interrupts the request still applies it.
|
|
1486
1804
|
this.preferredLayers.set(producerId, layers);
|
|
1487
|
-
|
|
1805
|
+
// Shown through attachVideo: the lower of this and the size it is shown at.
|
|
1806
|
+
const adaptive = this.adaptive.current(producerId);
|
|
1807
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1488
1808
|
}
|
|
1489
1809
|
async collectVideoStats(baseline) {
|
|
1490
1810
|
const producer = this.cameraProducer();
|
|
@@ -1523,7 +1843,8 @@ export class StreamClient {
|
|
|
1523
1843
|
}
|
|
1524
1844
|
this.receiveQualities.set(producerId, quality);
|
|
1525
1845
|
this.preferredLayers.set(producerId, layers);
|
|
1526
|
-
|
|
1846
|
+
const adaptive = this.adaptive.current(producerId);
|
|
1847
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...(adaptive ? this.effectiveLayers(producerId, adaptive) : layers) });
|
|
1527
1848
|
}
|
|
1528
1849
|
cameraProducer() {
|
|
1529
1850
|
for (const p of this.producers.values()) {
|
|
@@ -1615,14 +1936,65 @@ export class StreamClient {
|
|
|
1615
1936
|
// unpublish() may have run while this was being made (a publish, a
|
|
1616
1937
|
// quality change or a track swap under way).
|
|
1617
1938
|
await this.keepProducer(producer, generation, 'camera');
|
|
1939
|
+
this.watchTrackEnded(producer);
|
|
1940
|
+
this.syncPublishProtection();
|
|
1618
1941
|
this.cameraEncodings = encodings;
|
|
1619
1942
|
this.frameRateLowSamples = 0;
|
|
1620
1943
|
this.frameRateWarmupSamples = 2;
|
|
1621
1944
|
await this.pauseOnServer(producer);
|
|
1622
1945
|
await this.applyDegradationPreference(producer);
|
|
1623
1946
|
this.syncLimitWatch();
|
|
1947
|
+
// A new camera producer starts with every layer: put the performance and viewer limits back on it.
|
|
1948
|
+
await this.syncCameraLayers();
|
|
1624
1949
|
return producer;
|
|
1625
1950
|
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Lighten this device's work while it is overloaded (LiveKit's
|
|
1953
|
+
* `prioritizePerformance`): the camera's largest simulcast layer stops being
|
|
1954
|
+
* encoded -- the most expensive part of publishing -- and viewers get the
|
|
1955
|
+
* next one. `false` brings it back. Call it on `cpuConstrained`, together
|
|
1956
|
+
* with your app's own savings (effects off, smaller remote videos). Kept for
|
|
1957
|
+
* later publishes and reconnects until changed. A camera sent as one layer
|
|
1958
|
+
* is left as it is.
|
|
1959
|
+
*/
|
|
1960
|
+
async prioritizePerformance(on = true) {
|
|
1961
|
+
this.performanceFirst = on;
|
|
1962
|
+
await this.cameraTask(() => this.syncCameraLayers());
|
|
1963
|
+
}
|
|
1964
|
+
/** Whether `prioritizePerformance()` is in force. */
|
|
1965
|
+
get performancePrioritized() {
|
|
1966
|
+
return this.performanceFirst;
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The camera's active simulcast layers: every layer, except the top one while
|
|
1970
|
+
* performance comes first, and none above the highest any viewer needs
|
|
1971
|
+
* (dynacast; the smallest always stays on, so a new viewer starts at once).
|
|
1972
|
+
*/
|
|
1973
|
+
async syncCameraLayers() {
|
|
1974
|
+
const producer = this.cameraProducer();
|
|
1975
|
+
const count = this.cameraEncodings?.length ?? 0;
|
|
1976
|
+
if (!producer || producer.closed || count < 2)
|
|
1977
|
+
return;
|
|
1978
|
+
const top = count - 1;
|
|
1979
|
+
let max = top;
|
|
1980
|
+
if (this.performanceFirst)
|
|
1981
|
+
max = Math.min(max, top - 1);
|
|
1982
|
+
const needed = this.layersNeeded.get(producer.id);
|
|
1983
|
+
if (needed !== undefined)
|
|
1984
|
+
max = Math.min(max, Math.max(0, needed));
|
|
1985
|
+
// A producer just made sends every layer: nothing to ask of it until a limit applies.
|
|
1986
|
+
const applied = this.cameraMaxLayer?.producerId === producer.id ? this.cameraMaxLayer.layer : top;
|
|
1987
|
+
if (applied === max)
|
|
1988
|
+
return;
|
|
1989
|
+
try {
|
|
1990
|
+
await producer.setMaxSpatialLayer(max);
|
|
1991
|
+
this.cameraMaxLayer = { producerId: producer.id, layer: max };
|
|
1992
|
+
}
|
|
1993
|
+
catch (err) {
|
|
1994
|
+
if (!producer.closed)
|
|
1995
|
+
this.emit('error', err);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1626
1998
|
/**
|
|
1627
1999
|
* Keep a producer just made -- unless unpublish() ran while it was being
|
|
1628
2000
|
* made. Kept, it would publish again what was just unpublished, so it is
|
|
@@ -1697,7 +2069,7 @@ export class StreamClient {
|
|
|
1697
2069
|
}
|
|
1698
2070
|
/** videoLimited sampling runs while a camera is published and someone listens. */
|
|
1699
2071
|
syncLimitWatch() {
|
|
1700
|
-
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
|
|
2072
|
+
this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.listenerCount('cpuConstrained') > 0 || this.cameraFrameRate === 60));
|
|
1701
2073
|
}
|
|
1702
2074
|
/**
|
|
1703
2075
|
* Re-establish this client's broadcast after a reconnect.
|
|
@@ -2157,6 +2529,8 @@ export class StreamClient {
|
|
|
2157
2529
|
const consumer = await transport.consume(params);
|
|
2158
2530
|
current(consumer);
|
|
2159
2531
|
this.consumers.set(remote.producerId, consumer);
|
|
2532
|
+
if (this.playoutDelayMs !== null)
|
|
2533
|
+
this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2160
2534
|
this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
|
|
2161
2535
|
// Layers asked for before a reconnect: the new consumer gets them before
|
|
2162
2536
|
// its first frame instead of starting at the full picture. Not getting
|
|
@@ -2172,13 +2546,27 @@ export class StreamClient {
|
|
|
2172
2546
|
this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
|
|
2173
2547
|
}
|
|
2174
2548
|
}
|
|
2549
|
+
// Adaptive stream: a video the app shows through attachVideo starts at the
|
|
2550
|
+
// size it is shown, and one nobody can see stays paused.
|
|
2551
|
+
const adaptive = consumer.kind === 'video' ? this.adaptive.current(remote.producerId) : null;
|
|
2552
|
+
if (adaptive)
|
|
2553
|
+
layers = this.effectiveLayers(remote.producerId, adaptive);
|
|
2554
|
+
// A camera arriving while the audio-only fallback holds cameras back starts held back too.
|
|
2555
|
+
const held = consumer.kind === 'video' && this.protection.active && (remote.source ?? 'camera') === 'camera';
|
|
2175
2556
|
if (layers) {
|
|
2176
2557
|
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
|
|
2177
2558
|
}
|
|
2178
2559
|
current(consumer);
|
|
2179
2560
|
// The server created it paused. Resume only now that our end exists,
|
|
2180
2561
|
// otherwise the first frames arrive before there is anything to decode them.
|
|
2181
|
-
|
|
2562
|
+
this.adaptivePaused.delete(remote.producerId);
|
|
2563
|
+
if ((adaptive && !adaptive.visible) || (held && !this.pauseUnsupported))
|
|
2564
|
+
this.adaptivePaused.add(remote.producerId);
|
|
2565
|
+
else
|
|
2566
|
+
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
2567
|
+
if (held && this.pauseUnsupported) {
|
|
2568
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 }).catch(() => undefined);
|
|
2569
|
+
}
|
|
2182
2570
|
current(consumer);
|
|
2183
2571
|
const kind = consumer.kind;
|
|
2184
2572
|
const source = remote.source ?? defaultSource(kind);
|
|
@@ -2191,6 +2579,681 @@ export class StreamClient {
|
|
|
2191
2579
|
producerId: remote.producerId,
|
|
2192
2580
|
source,
|
|
2193
2581
|
});
|
|
2582
|
+
if (kind === 'video')
|
|
2583
|
+
this.adaptive.consumed(remote.producerId);
|
|
2584
|
+
if (held) {
|
|
2585
|
+
this.protectionHeld.add(remote.producerId);
|
|
2586
|
+
this.emitFallback(remote.producerId, true, 'audio-protection');
|
|
2587
|
+
}
|
|
2588
|
+
this.syncProtection();
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Adaptive stream (like LiveKit's adaptiveStream and Tencent TRTC's small
|
|
2592
|
+
* stream with receiveWhenViewVisible): tell the SDK which element shows a
|
|
2593
|
+
* remote video. It then receives the smallest layer that fills that element
|
|
2594
|
+
* sharply (its size on screen × devicePixelRatio, at most 2) and pauses the
|
|
2595
|
+
* video at the server while the element is not visible (scrolled away,
|
|
2596
|
+
* `display: none`, or the tab in the background), so a grid of small tiles
|
|
2597
|
+
* stops decoding full-size video. Attach the same video to several elements
|
|
2598
|
+
* and the largest visible one decides. Returns the function that detaches
|
|
2599
|
+
* it; detach when the element goes away. A ceiling set with
|
|
2600
|
+
* `setReceiveQuality` or `setPreferredLayers` still applies. Works with any
|
|
2601
|
+
* server; one without `pauseConsumer` gets the smallest layer instead of a
|
|
2602
|
+
* pause.
|
|
2603
|
+
*/
|
|
2604
|
+
attachVideo(producerId, element) {
|
|
2605
|
+
return this.adaptive.attach(producerId, element);
|
|
2606
|
+
}
|
|
2607
|
+
/** The layers to ask for: the app's ceiling (setReceiveQuality / setPreferredLayers) and adaptive stream's, the lower. */
|
|
2608
|
+
effectiveLayers(producerId, want) {
|
|
2609
|
+
let manual = this.preferredLayers.get(producerId);
|
|
2610
|
+
const quality = this.receiveQualities.get(producerId);
|
|
2611
|
+
if (quality && quality !== 'auto') {
|
|
2612
|
+
try {
|
|
2613
|
+
manual = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
|
|
2614
|
+
}
|
|
2615
|
+
catch {
|
|
2616
|
+
manual = undefined;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
const adaptive = want?.visible ? want.spatialLayer : null;
|
|
2620
|
+
if (adaptive === null || adaptive === undefined)
|
|
2621
|
+
return manual ?? { spatialLayer: -1, temporalLayer: -1 };
|
|
2622
|
+
if (!manual || manual.spatialLayer < 0)
|
|
2623
|
+
return { spatialLayer: adaptive };
|
|
2624
|
+
return { ...manual, spatialLayer: Math.min(manual.spatialLayer, adaptive) };
|
|
2625
|
+
}
|
|
2626
|
+
// ------------------------------------------------------- diagnostics
|
|
2627
|
+
/**
|
|
2628
|
+
* A snapshot for a support ticket (what Agora's log upload and Twilio's Insights are used for): the SDK version and
|
|
2629
|
+
* browser, the session (room, identity, role, state), how each media transport is connected (UDP, TCP or relayed),
|
|
2630
|
+
* the round trip and upload now, what is published and received, and the last 200 notable events (state changes,
|
|
2631
|
+
* errors, recoveries, fallbacks, warnings) with their times. Plain JSON: `JSON.stringify` it and attach it. Holds
|
|
2632
|
+
* no tokens and no media.
|
|
2633
|
+
*/
|
|
2634
|
+
async getDiagnostics() {
|
|
2635
|
+
const transport = async (t) => {
|
|
2636
|
+
if (!t || t.closed)
|
|
2637
|
+
return null;
|
|
2638
|
+
let path = null;
|
|
2639
|
+
try {
|
|
2640
|
+
path = connectionPath((await t.getStats()).values());
|
|
2641
|
+
}
|
|
2642
|
+
catch {
|
|
2643
|
+
// Best effort.
|
|
2644
|
+
}
|
|
2645
|
+
return { state: t.connectionState, path };
|
|
2646
|
+
};
|
|
2647
|
+
let network = null;
|
|
2648
|
+
try {
|
|
2649
|
+
const reports = await this.transportStats();
|
|
2650
|
+
const current = new Map([...reports].map(([key, report]) => [key, readTransportStats(report)]));
|
|
2651
|
+
if (current.size > 0) {
|
|
2652
|
+
const q = networkQualityOf(current, new Map(), this.producers.size > 0);
|
|
2653
|
+
network = { rttMs: q.rttMs, availableKbps: q.availableKbps };
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
catch {
|
|
2657
|
+
// Best effort.
|
|
2658
|
+
}
|
|
2659
|
+
const join = this.joinResult;
|
|
2660
|
+
return {
|
|
2661
|
+
sdk: SDK_VERSION,
|
|
2662
|
+
at: Date.now(),
|
|
2663
|
+
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
|
2664
|
+
connectionState: this.state,
|
|
2665
|
+
room: join?.roomId ?? null,
|
|
2666
|
+
identity: join?.identity ?? null,
|
|
2667
|
+
role: join?.role ?? null,
|
|
2668
|
+
transports: { send: await transport(this.sendTransport), recv: await transport(this.recvTransport) },
|
|
2669
|
+
network,
|
|
2670
|
+
published: [...this.producers.values()].filter((p) => !p.closed)
|
|
2671
|
+
.map((p) => ({ source: String(p.appData.source ?? p.kind), kind: p.kind, paused: p.paused })),
|
|
2672
|
+
received: [...this.consumers.entries()].filter(([, c]) => !c.closed).map(([producerId, c]) => ({
|
|
2673
|
+
identity: this.remoteInfo.get(producerId)?.identity ?? '',
|
|
2674
|
+
source: this.remoteSources.get(producerId) ?? (c.kind === 'audio' ? 'mic' : 'camera'),
|
|
2675
|
+
kind: c.kind,
|
|
2676
|
+
paused: c.paused || this.adaptivePaused.has(producerId),
|
|
2677
|
+
})),
|
|
2678
|
+
log: this.diagnosticsLog.list(),
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
// ------------------------------------------------------- autoplay
|
|
2682
|
+
/**
|
|
2683
|
+
* Play a `<video>` or `<audio>` showing a remote track, the way autoplay rules allow (Agora's and Tencent TRTC's
|
|
2684
|
+
* autoplay handling). Resolves true when it plays. When the browser refuses until the viewer interacts with the
|
|
2685
|
+
* page, resolves false, emits `autoplayFailed` with a `resume()` for a "Tap to unmute" button, and plays it -- with
|
|
2686
|
+
* every other refused element -- on the next tap, click or key press by itself.
|
|
2687
|
+
*/
|
|
2688
|
+
async play(element) {
|
|
2689
|
+
try {
|
|
2690
|
+
await element.play();
|
|
2691
|
+
this.autoplayBlocked.delete(element);
|
|
2692
|
+
return true;
|
|
2693
|
+
}
|
|
2694
|
+
catch (err) {
|
|
2695
|
+
if (err?.name !== 'NotAllowedError')
|
|
2696
|
+
throw err;
|
|
2697
|
+
this.autoplayBlocked.add(element);
|
|
2698
|
+
this.watchGesture(true);
|
|
2699
|
+
this.emit('autoplayFailed', { element, resume: async () => { await this.play(element); } });
|
|
2700
|
+
return false;
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
watchGesture(on) {
|
|
2704
|
+
if (typeof document === 'undefined' || on === this.gestureWatchOn)
|
|
2705
|
+
return;
|
|
2706
|
+
this.gestureWatchOn = on;
|
|
2707
|
+
for (const type of ['pointerdown', 'keydown', 'touchend']) {
|
|
2708
|
+
if (on)
|
|
2709
|
+
document.addEventListener(type, this.onGesture, { capture: true });
|
|
2710
|
+
else
|
|
2711
|
+
document.removeEventListener(type, this.onGesture, { capture: true });
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
async retryAutoplay() {
|
|
2715
|
+
for (const element of [...this.autoplayBlocked]) {
|
|
2716
|
+
try {
|
|
2717
|
+
await element.play();
|
|
2718
|
+
this.autoplayBlocked.delete(element);
|
|
2719
|
+
}
|
|
2720
|
+
catch {
|
|
2721
|
+
// Still refused (or the element went away); the next gesture tries again.
|
|
2722
|
+
if (!element.isConnected)
|
|
2723
|
+
this.autoplayBlocked.delete(element);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
if (this.autoplayBlocked.size === 0)
|
|
2727
|
+
this.watchGesture(false);
|
|
2728
|
+
}
|
|
2729
|
+
// ------------------------------------------------------- playout delay
|
|
2730
|
+
/**
|
|
2731
|
+
* Trade a little delay for smoother playback (Tencent TRTC's playoutDelay): every remote track is buffered about
|
|
2732
|
+
* `ms` before it plays, which rides out the jitter of a mobile network without freezes. For an audience watching a
|
|
2733
|
+
* stream, 300-500 ms is a good value; people talking with each other want `null` (the browser's own, as small as
|
|
2734
|
+
* it can keep). Applies to what is received now and later. Browsers without the setting (before Chrome 114) keep
|
|
2735
|
+
* their own; 0-4000 ms.
|
|
2736
|
+
*/
|
|
2737
|
+
setPlayoutDelay(ms) {
|
|
2738
|
+
this.playoutDelayMs = ms === null ? null : Math.min(4000, Math.max(0, Math.round(ms)));
|
|
2739
|
+
for (const consumer of this.consumers.values())
|
|
2740
|
+
this.applyPlayoutDelay(consumer.rtpReceiver);
|
|
2741
|
+
}
|
|
2742
|
+
applyPlayoutDelay(receiver) {
|
|
2743
|
+
if (!receiver || !('jitterBufferTarget' in receiver))
|
|
2744
|
+
return;
|
|
2745
|
+
try {
|
|
2746
|
+
receiver.jitterBufferTarget = this.playoutDelayMs;
|
|
2747
|
+
}
|
|
2748
|
+
catch {
|
|
2749
|
+
// Out of the browser's range: it keeps its own.
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
// ------------------------------------------------------- audio levels
|
|
2753
|
+
/** Levels are read only while someone listens and the session is connected. */
|
|
2754
|
+
syncLevelWatch() {
|
|
2755
|
+
const wanted = !this.closedByUs && this.state === 'connected' &&
|
|
2756
|
+
(this.listenerCount('audioLevels') > 0 || this.listenerCount('activeSpeakers') > 0);
|
|
2757
|
+
if (wanted && !this.levelTimer) {
|
|
2758
|
+
this.levelTimer = setInterval(() => void this.sampleAudioLevels(), AUDIO_LEVEL_INTERVAL_MS);
|
|
2759
|
+
this.levelTimer.unref?.();
|
|
2760
|
+
}
|
|
2761
|
+
else if (!wanted) {
|
|
2762
|
+
this.stopLevelWatch();
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
stopLevelWatch() {
|
|
2766
|
+
if (this.levelTimer)
|
|
2767
|
+
clearInterval(this.levelTimer);
|
|
2768
|
+
this.levelTimer = null;
|
|
2769
|
+
this.speakers.reset();
|
|
2770
|
+
}
|
|
2771
|
+
/**
|
|
2772
|
+
* One reading: each remote audio from its receiver's synchronization sources (the most recent packet's level, no
|
|
2773
|
+
* statistics report needed), the own microphone from its sender's media-source statistics.
|
|
2774
|
+
*/
|
|
2775
|
+
async sampleAudioLevels() {
|
|
2776
|
+
if (this.levelSampling)
|
|
2777
|
+
return;
|
|
2778
|
+
this.levelSampling = true;
|
|
2779
|
+
try {
|
|
2780
|
+
const levels = [];
|
|
2781
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
2782
|
+
if (consumer.kind !== 'audio' || consumer.closed)
|
|
2783
|
+
continue;
|
|
2784
|
+
const info = this.remoteInfo.get(producerId);
|
|
2785
|
+
if (!info)
|
|
2786
|
+
continue;
|
|
2787
|
+
const level = info.paused ? 0 : receiverLevel(consumer.rtpReceiver);
|
|
2788
|
+
levels.push({ identity: info.identity, producerId, source: this.remoteSources.get(producerId) ?? 'mic', level, speaking: level > SPEAKING_LEVEL, local: false });
|
|
2789
|
+
}
|
|
2790
|
+
const mic = this.micProducer();
|
|
2791
|
+
const self = this.joinResult?.identity;
|
|
2792
|
+
if (mic && !mic.paused && self) {
|
|
2793
|
+
const level = await senderLevel(mic);
|
|
2794
|
+
levels.push({ identity: self, producerId: mic.id, source: 'mic', level, speaking: level > SPEAKING_LEVEL, local: true });
|
|
2795
|
+
}
|
|
2796
|
+
if (!this.levelTimer)
|
|
2797
|
+
return;
|
|
2798
|
+
if (this.listenerCount('audioLevels') > 0)
|
|
2799
|
+
this.emit('audioLevels', levels);
|
|
2800
|
+
const loudest = new Map();
|
|
2801
|
+
for (const l of levels)
|
|
2802
|
+
if (l.source === 'mic')
|
|
2803
|
+
loudest.set(l.identity, Math.max(loudest.get(l.identity) ?? 0, l.level));
|
|
2804
|
+
const speakers = this.speakers.update(loudest);
|
|
2805
|
+
if (speakers)
|
|
2806
|
+
this.emit('activeSpeakers', { speakers });
|
|
2807
|
+
}
|
|
2808
|
+
catch {
|
|
2809
|
+
// Best effort; the next reading tries again.
|
|
2810
|
+
}
|
|
2811
|
+
finally {
|
|
2812
|
+
this.levelSampling = false;
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
// ------------------------------------------------------- media health
|
|
2816
|
+
/** The published devices are sampled only while someone listens and the session is connected. */
|
|
2817
|
+
syncHealthWatch() {
|
|
2818
|
+
const wanted = !this.closedByUs && this.state === 'connected' && this.listenerCount('mediaWarning') > 0;
|
|
2819
|
+
if (wanted && !this.healthTimer) {
|
|
2820
|
+
this.healthTimer = setInterval(() => void this.sampleHealth(), HEALTH_SAMPLE_MS);
|
|
2821
|
+
this.healthTimer.unref?.();
|
|
2822
|
+
}
|
|
2823
|
+
else if (!wanted) {
|
|
2824
|
+
this.stopHealthWatch(this.listenerCount('mediaWarning') > 0 && !this.closedByUs);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
/** Stop sampling; `report` says each active warning cleared (nothing is said after close). */
|
|
2828
|
+
stopHealthWatch(report) {
|
|
2829
|
+
if (this.healthTimer)
|
|
2830
|
+
clearInterval(this.healthTimer);
|
|
2831
|
+
this.healthTimer = null;
|
|
2832
|
+
for (const timer of this.interruptions.values())
|
|
2833
|
+
clearTimeout(timer);
|
|
2834
|
+
this.interruptions.clear();
|
|
2835
|
+
if (report)
|
|
2836
|
+
this.health.clearAll();
|
|
2837
|
+
}
|
|
2838
|
+
async sampleHealth() {
|
|
2839
|
+
if (this.healthSampling)
|
|
2840
|
+
return;
|
|
2841
|
+
this.healthSampling = true;
|
|
2842
|
+
try {
|
|
2843
|
+
const mic = this.micProducer();
|
|
2844
|
+
const camera = this.cameraProducer();
|
|
2845
|
+
const micLevel = mic && !mic.paused ? readMediaSource((await mic.getStats()).values()).level : null;
|
|
2846
|
+
const cameraFps = camera && !camera.paused ? readMediaSource((await camera.getStats()).values()).fps : null;
|
|
2847
|
+
if (!this.healthTimer)
|
|
2848
|
+
return;
|
|
2849
|
+
this.health.feed({ micLevel, cameraFps, askedFps: this.videoFrameRate });
|
|
2850
|
+
// An interrupted device that is no longer published never says it came back: that is over too.
|
|
2851
|
+
if (!mic)
|
|
2852
|
+
this.health.interrupted('mic-interrupted', false);
|
|
2853
|
+
if (!camera)
|
|
2854
|
+
this.health.interrupted('camera-interrupted', false);
|
|
2855
|
+
}
|
|
2856
|
+
catch {
|
|
2857
|
+
// Best effort; the next sample tries again.
|
|
2858
|
+
}
|
|
2859
|
+
finally {
|
|
2860
|
+
this.healthSampling = false;
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
// ---------------------------------------------------------------- devices
|
|
2864
|
+
/**
|
|
2865
|
+
* The cameras, microphones and speakers the browser offers, optionally of one kind. Labels are empty until the
|
|
2866
|
+
* page has been granted camera or microphone access.
|
|
2867
|
+
*/
|
|
2868
|
+
listDevices(kind) {
|
|
2869
|
+
return enumerate(kind);
|
|
2870
|
+
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Publish from another camera or microphone (LiveKit's switchActiveDevice, Agora's setDevice). The device is
|
|
2873
|
+
* captured with the constraints publish was given (size, frame rate, echo cancellation) and its track replaces the
|
|
2874
|
+
* published one: a camera of the same size swaps with no pause for viewers, a microphone always does. The track it
|
|
2875
|
+
* replaces is stopped, releasing that device; the client's own stream follows (`localStreamChanged`), and a
|
|
2876
|
+
* reconnect captures the chosen device again. A camera or microphone that is switched off stays off.
|
|
2877
|
+
*
|
|
2878
|
+
* For a camera published as a canvas or another processed track, capture the new camera yourself and pass your
|
|
2879
|
+
* processed track to `replaceVideoTrack`; this rejects rather than replace your picture with the raw camera.
|
|
2880
|
+
* Rejects when that kind is not being published.
|
|
2881
|
+
*/
|
|
2882
|
+
async switchDevice(kind, deviceId) {
|
|
2883
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
2884
|
+
if (!md?.getUserMedia)
|
|
2885
|
+
throw new Error('switchDevice: this browser cannot capture media');
|
|
2886
|
+
if (kind === 'videoinput') {
|
|
2887
|
+
const current = this.cameraProducer()?.track ?? this.localStream?.getVideoTracks()[0] ?? null;
|
|
2888
|
+
if (!current)
|
|
2889
|
+
throw new Error('switchDevice: no camera is being published');
|
|
2890
|
+
if (!deviceIdOf(current)) {
|
|
2891
|
+
throw new Error('switchDevice: the published video is not a camera capture (a canvas or processed track); capture the camera yourself and call replaceVideoTrack');
|
|
2892
|
+
}
|
|
2893
|
+
const video = constraintsFor(this.publishConstraints?.video, deviceId);
|
|
2894
|
+
const track = (await md.getUserMedia({ video })).getVideoTracks()[0];
|
|
2895
|
+
if (!track)
|
|
2896
|
+
throw new Error('switchDevice: the camera gave no video');
|
|
2897
|
+
try {
|
|
2898
|
+
await this.replaceVideoTrack(track);
|
|
2899
|
+
}
|
|
2900
|
+
catch (err) {
|
|
2901
|
+
track.stop();
|
|
2902
|
+
throw err;
|
|
2903
|
+
}
|
|
2904
|
+
if (current !== track)
|
|
2905
|
+
current.stop();
|
|
2906
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), video };
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
await this.micTask(async () => {
|
|
2910
|
+
const producer = this.micProducer();
|
|
2911
|
+
if (!producer)
|
|
2912
|
+
throw new Error('switchDevice: no microphone is being published');
|
|
2913
|
+
const audio = constraintsFor(this.publishConstraints?.audio, deviceId);
|
|
2914
|
+
const track = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
2915
|
+
if (!track)
|
|
2916
|
+
throw new Error('switchDevice: the microphone gave no audio');
|
|
2917
|
+
if (producer.closed || this.micProducer() !== producer) {
|
|
2918
|
+
track.stop();
|
|
2919
|
+
throw new Error('switchDevice: the microphone was unpublished meanwhile');
|
|
2920
|
+
}
|
|
2921
|
+
await this.adoptAudioTrack(producer, track);
|
|
2922
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
/** Put `track` on the microphone producer and in the client's own stream; the replaced track is stopped. */
|
|
2926
|
+
async adoptAudioTrack(producer, track) {
|
|
2927
|
+
const previous = producer.track;
|
|
2928
|
+
await producer.replaceTrack({ track });
|
|
2929
|
+
const video = this.localStream ? this.localStream.getTracks().filter((t) => t.kind !== 'audio') : [];
|
|
2930
|
+
this.localStream = new MediaStream([track, ...video]);
|
|
2931
|
+
this.emit('localStreamChanged', this.localStream);
|
|
2932
|
+
if (previous && previous !== track)
|
|
2933
|
+
previous.stop();
|
|
2934
|
+
this.watchTrackEnded(producer);
|
|
2935
|
+
}
|
|
2936
|
+
/** Notice when the producer's present track stops from outside (unplugged, permission revoked). */
|
|
2937
|
+
watchTrackEnded(producer) {
|
|
2938
|
+
const track = producer.track;
|
|
2939
|
+
if (!track || typeof track.addEventListener !== 'function')
|
|
2940
|
+
return;
|
|
2941
|
+
track.addEventListener('ended', () => { void this.onTrackEnded(producer, track); }, { once: true });
|
|
2942
|
+
// The system taking the device (a call, another app) mutes the track; held a moment, it is reported.
|
|
2943
|
+
const which = producer.appData.source === 'mic' ? 'mic' : producer.appData.source === 'camera' ? 'camera' : null;
|
|
2944
|
+
if (!which)
|
|
2945
|
+
return;
|
|
2946
|
+
const code = which === 'mic' ? 'mic-interrupted' : 'camera-interrupted';
|
|
2947
|
+
const current = () => !this.closedByUs && !producer.closed && producer.track === track;
|
|
2948
|
+
track.addEventListener('mute', () => {
|
|
2949
|
+
if (!current() || this.interruptions.has(which))
|
|
2950
|
+
return;
|
|
2951
|
+
this.interruptions.set(which, setTimeout(() => {
|
|
2952
|
+
this.interruptions.delete(which);
|
|
2953
|
+
if (current() && track.muted)
|
|
2954
|
+
this.health.interrupted(code, true);
|
|
2955
|
+
}, INTERRUPTED_AFTER_MS));
|
|
2956
|
+
});
|
|
2957
|
+
track.addEventListener('unmute', () => {
|
|
2958
|
+
const timer = this.interruptions.get(which);
|
|
2959
|
+
if (timer)
|
|
2960
|
+
clearTimeout(timer);
|
|
2961
|
+
this.interruptions.delete(which);
|
|
2962
|
+
if (current())
|
|
2963
|
+
this.health.interrupted(code, false);
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* A published camera or microphone stopped from outside. The microphone comes back from the system default (Chime
|
|
2968
|
+
* keeps audio flowing, LiveKit moves to the first available device); a camera is switched off and reported, never
|
|
2969
|
+
* swapped for another camera on its own (Chime stops it the same way).
|
|
2970
|
+
*/
|
|
2971
|
+
async onTrackEnded(producer, track) {
|
|
2972
|
+
const live = () => !this.closedByUs && !producer.closed && producer.track === track && this.producers.get(producer.id) === producer;
|
|
2973
|
+
if (!live())
|
|
2974
|
+
return;
|
|
2975
|
+
const deviceId = deviceIdOf(track);
|
|
2976
|
+
if (producer.appData.source === 'mic') {
|
|
2977
|
+
await this.micTask(async () => {
|
|
2978
|
+
if (!live())
|
|
2979
|
+
return;
|
|
2980
|
+
try {
|
|
2981
|
+
const md = navigator.mediaDevices;
|
|
2982
|
+
const audio = constraintsFor(this.publishConstraints?.audio, null);
|
|
2983
|
+
const next = (await md.getUserMedia({ audio })).getAudioTracks()[0];
|
|
2984
|
+
if (!next)
|
|
2985
|
+
throw new Error('no microphone');
|
|
2986
|
+
if (!live()) {
|
|
2987
|
+
next.stop();
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
await this.adoptAudioTrack(producer, next);
|
|
2991
|
+
this.publishConstraints = { ...(this.publishConstraints ?? {}), audio };
|
|
2992
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: true });
|
|
2993
|
+
}
|
|
2994
|
+
catch {
|
|
2995
|
+
if (!live())
|
|
2996
|
+
return;
|
|
2997
|
+
await this.setMicEnabled(false).catch(() => undefined);
|
|
2998
|
+
this.emit('localTrackEnded', { kind: 'audio', deviceId, recovered: false });
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
if (producer.appData.source === 'camera') {
|
|
3004
|
+
await this.setCamEnabled(false).catch(() => undefined);
|
|
3005
|
+
if (!this.closedByUs)
|
|
3006
|
+
this.emit('localTrackEnded', { kind: 'video', deviceId, recovered: false });
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
/** devicesChanged is watched only while someone listens. */
|
|
3010
|
+
syncDeviceWatch() {
|
|
3011
|
+
const md = typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined;
|
|
3012
|
+
const wanted = !this.closedByUs && this.listenerCount('devicesChanged') > 0 && typeof md?.addEventListener === 'function';
|
|
3013
|
+
if (wanted && !this.deviceWatchOn) {
|
|
3014
|
+
this.deviceWatchOn = true;
|
|
3015
|
+
md.addEventListener('devicechange', this.onDeviceChange);
|
|
3016
|
+
void enumerate().then((list) => { if (this.deviceWatchOn)
|
|
3017
|
+
this.knownDevices = list; }).catch(() => undefined);
|
|
3018
|
+
}
|
|
3019
|
+
else if (!wanted) {
|
|
3020
|
+
this.stopDeviceWatch();
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
stopDeviceWatch() {
|
|
3024
|
+
if (!this.deviceWatchOn)
|
|
3025
|
+
return;
|
|
3026
|
+
this.deviceWatchOn = false;
|
|
3027
|
+
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
3028
|
+
}
|
|
3029
|
+
async refreshDevices() {
|
|
3030
|
+
let now;
|
|
3031
|
+
try {
|
|
3032
|
+
now = await enumerate();
|
|
3033
|
+
}
|
|
3034
|
+
catch {
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
if (!this.deviceWatchOn)
|
|
3038
|
+
return;
|
|
3039
|
+
const { added, removed } = diffDevices(this.knownDevices, now);
|
|
3040
|
+
this.knownDevices = now;
|
|
3041
|
+
if (added.length > 0 || removed.length > 0)
|
|
3042
|
+
this.emit('devicesChanged', { devices: now, added, removed });
|
|
3043
|
+
}
|
|
3044
|
+
/** Adaptive stream's decision for a video, with the audio-only fallback holding remote cameras back. */
|
|
3045
|
+
withFallback(producerId, want) {
|
|
3046
|
+
if (this.protection.active && this.remoteSources.get(producerId) === 'camera')
|
|
3047
|
+
return { visible: false, spatialLayer: null };
|
|
3048
|
+
return want;
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Audio-only fallback (like Agora's AUDIO_ONLY stream fallback): on unless `audioOnlyFallback: false` was given.
|
|
3052
|
+
* While this viewer's received sound breaks up badly -- a quarter of it lost over about 15 s -- remote camera
|
|
3053
|
+
* videos are paused at the server (screen shares stay) so what bandwidth there is goes to the audio; they come
|
|
3054
|
+
* back once it has recovered for a while, holding longer if it falls back again soon. Each held video is reported
|
|
3055
|
+
* by `streamFallback`. `false` brings any held video back now and stops watching.
|
|
3056
|
+
*/
|
|
3057
|
+
setAudioOnlyFallback(enabled) {
|
|
3058
|
+
this.audioOnlyEnabled = enabled;
|
|
3059
|
+
if (!enabled)
|
|
3060
|
+
this.protection.release();
|
|
3061
|
+
this.syncProtection();
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* The publisher side of the audio-only fallback (Agora's setLocalPublishFallbackOption(AUDIO_ONLY)), off by default:
|
|
3065
|
+
* while this client's own sound breaks up on the way to the server -- a quarter of it lost over about 15 s -- its
|
|
3066
|
+
* camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers, holding
|
|
3067
|
+
* longer after a relapse. Reported by `publishFallback`. A camera you or a moderator switch off meanwhile stays off.
|
|
3068
|
+
* `false` brings a held camera back now.
|
|
3069
|
+
*/
|
|
3070
|
+
setPublishFallback(enabled) {
|
|
3071
|
+
this.publishFallbackEnabled = enabled;
|
|
3072
|
+
if (!enabled)
|
|
3073
|
+
this.publishProtection.release();
|
|
3074
|
+
this.syncPublishProtection();
|
|
3075
|
+
}
|
|
3076
|
+
syncPublishProtection() {
|
|
3077
|
+
const wanted = this.publishFallbackEnabled && !this.closedByUs && this.state === 'connected' &&
|
|
3078
|
+
this.micProducer() !== undefined && (this.cameraProducer() !== undefined || this.publishProtection.active);
|
|
3079
|
+
if (wanted && !this.publishProtectionTimer) {
|
|
3080
|
+
this.publishProtectionTimer = setInterval(() => void this.samplePublishProtection(), PROTECT_SAMPLE_MS);
|
|
3081
|
+
this.publishProtectionTimer.unref?.();
|
|
3082
|
+
}
|
|
3083
|
+
else if (!wanted && this.publishProtectionTimer) {
|
|
3084
|
+
clearInterval(this.publishProtectionTimer);
|
|
3085
|
+
this.publishProtectionTimer = null;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
async samplePublishProtection() {
|
|
3089
|
+
const transport = this.sendTransport;
|
|
3090
|
+
if (this.publishProtectionSampling || !transport || transport.closed)
|
|
3091
|
+
return;
|
|
3092
|
+
this.publishProtectionSampling = true;
|
|
3093
|
+
try {
|
|
3094
|
+
const report = await transport.getStats();
|
|
3095
|
+
if (transport !== this.sendTransport || !this.publishProtectionTimer)
|
|
3096
|
+
return;
|
|
3097
|
+
this.publishProtection.feed(readOutboundAudio(report.values()));
|
|
3098
|
+
}
|
|
3099
|
+
catch {
|
|
3100
|
+
// Best effort.
|
|
3101
|
+
}
|
|
3102
|
+
finally {
|
|
3103
|
+
this.publishProtectionSampling = false;
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
/** The publisher-side fallback decided: hold the camera, or bring it back if it is still ours to bring back. */
|
|
3107
|
+
async onPublishProtection(active) {
|
|
3108
|
+
const camera = this.cameraProducer();
|
|
3109
|
+
if (active) {
|
|
3110
|
+
if (!camera || camera.paused) {
|
|
3111
|
+
this.syncPublishProtection();
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
this.cameraHeldByFallback = true;
|
|
3115
|
+
camera.pause();
|
|
3116
|
+
this.emit('publishFallback', { fallback: true });
|
|
3117
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: true }).catch(() => undefined);
|
|
3118
|
+
this.emitLocalMedia();
|
|
3119
|
+
}
|
|
3120
|
+
else if (this.cameraHeldByFallback) {
|
|
3121
|
+
this.cameraHeldByFallback = false;
|
|
3122
|
+
if (camera && camera.paused && !this.forcedOff.video) {
|
|
3123
|
+
camera.resume();
|
|
3124
|
+
await this.rpc('pauseProducer', { producerId: camera.id, paused: false }).catch(() => undefined);
|
|
3125
|
+
this.emitLocalMedia();
|
|
3126
|
+
}
|
|
3127
|
+
this.emit('publishFallback', { fallback: false });
|
|
3128
|
+
}
|
|
3129
|
+
this.syncPublishProtection();
|
|
3130
|
+
}
|
|
3131
|
+
/** Whether the audio-only fallback is holding remote cameras back now. */
|
|
3132
|
+
get audioOnlyFallbackActive() {
|
|
3133
|
+
return this.protection.active;
|
|
3134
|
+
}
|
|
3135
|
+
/** The audio-only fallback decided: hold remote cameras back, or let them come back. */
|
|
3136
|
+
onAudioProtection(active) {
|
|
3137
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3138
|
+
if (consumer.kind !== 'video' || this.remoteSources.get(producerId) !== 'camera')
|
|
3139
|
+
continue;
|
|
3140
|
+
const want = this.withFallback(producerId, this.adaptive.current(producerId) ?? { visible: true, spatialLayer: null });
|
|
3141
|
+
void this.applyAdaptive(producerId, want);
|
|
3142
|
+
if (active && !this.protectionHeld.has(producerId)) {
|
|
3143
|
+
this.protectionHeld.add(producerId);
|
|
3144
|
+
this.emitFallback(producerId, true, 'audio-protection');
|
|
3145
|
+
}
|
|
3146
|
+
else if (!active && this.protectionHeld.delete(producerId)) {
|
|
3147
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
this.syncProtection();
|
|
3151
|
+
}
|
|
3152
|
+
emitFallback(producerId, fallback, reason) {
|
|
3153
|
+
const info = this.remoteInfo.get(producerId);
|
|
3154
|
+
this.emit('streamFallback', {
|
|
3155
|
+
producerId,
|
|
3156
|
+
identity: info?.identity ?? '',
|
|
3157
|
+
source: this.remoteSources.get(producerId) ?? 'camera',
|
|
3158
|
+
fallback,
|
|
3159
|
+
reason,
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
/** Withdraw every held-back report (teardown; nothing is said after close) and forget the decisions. */
|
|
3163
|
+
clearFallbacks() {
|
|
3164
|
+
if (!this.closedByUs) {
|
|
3165
|
+
for (const producerId of this.bandwidthCut)
|
|
3166
|
+
this.emitFallback(producerId, false, 'bandwidth');
|
|
3167
|
+
for (const producerId of this.protectionHeld)
|
|
3168
|
+
this.emitFallback(producerId, false, 'audio-protection');
|
|
3169
|
+
}
|
|
3170
|
+
this.bandwidthCut.clear();
|
|
3171
|
+
this.protectionHeld.clear();
|
|
3172
|
+
this.protection.reset();
|
|
3173
|
+
this.stopProtection();
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* The received audio is sampled only while the fallback could matter: enabled, connected, with remote audio to
|
|
3177
|
+
* judge by and a remote camera to hold back (or one held back already).
|
|
3178
|
+
*/
|
|
3179
|
+
syncProtection() {
|
|
3180
|
+
let audio = false;
|
|
3181
|
+
let camera = false;
|
|
3182
|
+
for (const [producerId, consumer] of this.consumers) {
|
|
3183
|
+
if (consumer.closed)
|
|
3184
|
+
continue;
|
|
3185
|
+
if (consumer.kind === 'audio')
|
|
3186
|
+
audio = true;
|
|
3187
|
+
else if (this.remoteSources.get(producerId) === 'camera')
|
|
3188
|
+
camera = true;
|
|
3189
|
+
}
|
|
3190
|
+
const wanted = this.audioOnlyEnabled && !this.closedByUs && this.state === 'connected' && audio && (camera || this.protection.active);
|
|
3191
|
+
if (wanted && !this.protectionTimer) {
|
|
3192
|
+
this.protectionTimer = setInterval(() => void this.sampleProtection(), PROTECT_SAMPLE_MS);
|
|
3193
|
+
// Background sampling never keeps a server-side or test process alive (Node); browsers have no unref.
|
|
3194
|
+
this.protectionTimer.unref?.();
|
|
3195
|
+
}
|
|
3196
|
+
else if (!wanted) {
|
|
3197
|
+
this.stopProtection();
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
stopProtection() {
|
|
3201
|
+
if (this.protectionTimer)
|
|
3202
|
+
clearInterval(this.protectionTimer);
|
|
3203
|
+
this.protectionTimer = null;
|
|
3204
|
+
}
|
|
3205
|
+
async sampleProtection() {
|
|
3206
|
+
const transport = this.recvTransport;
|
|
3207
|
+
if (this.protectionSampling || !transport || transport.closed)
|
|
3208
|
+
return;
|
|
3209
|
+
this.protectionSampling = true;
|
|
3210
|
+
try {
|
|
3211
|
+
const report = await transport.getStats();
|
|
3212
|
+
if (transport !== this.recvTransport || !this.protectionTimer)
|
|
3213
|
+
return;
|
|
3214
|
+
this.protection.feed(readInboundAudio(report.values()));
|
|
3215
|
+
}
|
|
3216
|
+
catch {
|
|
3217
|
+
// Statistics are best effort; the next sample tries again.
|
|
3218
|
+
}
|
|
3219
|
+
finally {
|
|
3220
|
+
this.protectionSampling = false;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
/** Apply adaptive stream's decision for one video: pause it, or resume it at its layer. */
|
|
3224
|
+
async applyAdaptive(producerId, want) {
|
|
3225
|
+
const consumer = this.consumers.get(producerId);
|
|
3226
|
+
// Not received (yet): a consumer made later starts from the decision in force.
|
|
3227
|
+
if (!consumer || consumer.kind !== 'video' || consumer.closed || this.closedByUs)
|
|
3228
|
+
return;
|
|
3229
|
+
try {
|
|
3230
|
+
if (!want.visible) {
|
|
3231
|
+
if (this.pauseUnsupported) {
|
|
3232
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, spatialLayer: 0, temporalLayer: 0 });
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
if (this.adaptivePaused.has(producerId))
|
|
3236
|
+
return;
|
|
3237
|
+
this.adaptivePaused.add(producerId);
|
|
3238
|
+
await this.rpc('pauseConsumer', { consumerId: consumer.id });
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (this.adaptivePaused.delete(producerId))
|
|
3242
|
+
await this.rpc('resumeConsumer', { consumerId: consumer.id });
|
|
3243
|
+
await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...this.effectiveLayers(producerId, want) });
|
|
3244
|
+
}
|
|
3245
|
+
catch (err) {
|
|
3246
|
+
if (this.consumers.get(producerId) !== consumer || consumer.closed || this.closedByUs)
|
|
3247
|
+
return;
|
|
3248
|
+
if (!want.visible && err instanceof RpcError && err.code === 'unknown-method') {
|
|
3249
|
+
// An older server: drop to the smallest layer instead of pausing.
|
|
3250
|
+
this.adaptivePaused.delete(producerId);
|
|
3251
|
+
this.pauseUnsupported = true;
|
|
3252
|
+
await this.applyAdaptive(producerId, want);
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
this.emit('error', err);
|
|
3256
|
+
}
|
|
2194
3257
|
}
|
|
2195
3258
|
// ------------------------------------------------------------- moderation
|
|
2196
3259
|
// ------------------------------------------------------------ local media
|
|
@@ -2238,6 +3301,12 @@ export class StreamClient {
|
|
|
2238
3301
|
this.music?.setEnabled(false);
|
|
2239
3302
|
}
|
|
2240
3303
|
async setKindEnabled(kind, enabled) {
|
|
3304
|
+
// Your own choice about the camera ends the publisher-side fallback's hold on it: it never undoes your choice.
|
|
3305
|
+
if (kind === 'video' && this.cameraHeldByFallback) {
|
|
3306
|
+
this.cameraHeldByFallback = false;
|
|
3307
|
+
this.publishProtection.reset();
|
|
3308
|
+
this.emit('publishFallback', { fallback: false });
|
|
3309
|
+
}
|
|
2241
3310
|
const generation = this.publishGeneration;
|
|
2242
3311
|
// Unpublished or closed while waiting: nothing is left to switch, and the
|
|
2243
3312
|
// call resolves, as it does with nothing published (V072-03).
|
|
@@ -2288,6 +3357,7 @@ export class StreamClient {
|
|
|
2288
3357
|
/** Stop publishing entirely and release the local devices. */
|
|
2289
3358
|
async unpublish() {
|
|
2290
3359
|
void this.stopRecording().catch(error => this.emit('error', error));
|
|
3360
|
+
this.layersNeeded.clear();
|
|
2291
3361
|
// All of it takes effect now; only telling the server is waited for. So
|
|
2292
3362
|
// publish() and unpublish() act in the order they are called: a publish
|
|
2293
3363
|
// called while this still waits is not undone by it, and one already
|