livekit-client 2.21.0 → 2.22.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/README.md +10 -10
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +2 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -150
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +2 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/room/PCTransport.d.ts +49 -2
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +20 -1
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +2 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts +19 -10
- package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteTrack.d.ts +3 -0
- package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +15 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/promiseState.d.ts +12 -0
- package/dist/src/test/promiseState.d.ts.map +1 -0
- package/dist/src/test/signalToken.d.ts.map +1 -1
- package/dist/src/utils/AsyncQueue.d.ts +3 -3
- package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
- package/dist/ts4.2/room/PCTransport.d.ts +49 -2
- package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
- package/dist/ts4.2/room/Room.d.ts +2 -0
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
- package/dist/ts4.2/room/utils.d.ts +15 -0
- package/dist/ts4.2/test/promiseState.d.ts +12 -0
- package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
- package/package.json +13 -12
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/e2ee/utils.ts +1 -1
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +181 -80
- package/src/room/RTCEngine.test.ts +339 -2
- package/src/room/RTCEngine.ts +152 -11
- package/src/room/Room.test.ts +134 -3
- package/src/room/Room.ts +52 -17
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
- package/src/room/data-stream/incoming/StreamReader.ts +1 -1
- package/src/room/errors.ts +1 -2
- package/src/room/token-source/TokenSource.ts +25 -12
- package/src/room/track/LocalVideoTrack.test.ts +105 -2
- package/src/room/track/LocalVideoTrack.ts +36 -10
- package/src/room/track/RemoteTrack.test.ts +144 -0
- package/src/room/track/RemoteTrack.ts +39 -12
- package/src/room/track/Track.ts +2 -1
- package/src/room/utils.test.ts +71 -2
- package/src/room/utils.ts +52 -0
- package/src/test/promiseState.ts +23 -0
- package/src/test/signalServerSetup.ts +2 -1
- package/src/test/signalToken.ts +17 -13
- package/src/type-polyfills/header-extensions.d.ts +13 -0
- package/src/utils/AsyncQueue.ts +3 -3
|
@@ -213,29 +213,34 @@ class TokenSourceEndpoint extends TokenSourceCached {
|
|
|
213
213
|
const body = await response.json();
|
|
214
214
|
return TokenSourceResponse.fromJson(body, {
|
|
215
215
|
// NOTE: it could be possible that the response body could contain more fields than just
|
|
216
|
-
// what's in TokenSourceResponse depending on the implementation (ie,
|
|
216
|
+
// what's in TokenSourceResponse depending on the implementation (ie, DevelopmentTokenServer)
|
|
217
217
|
ignoreUnknownFields: true,
|
|
218
218
|
});
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
export type
|
|
222
|
+
export type DevelopmentTokenServerOptions = {
|
|
223
223
|
baseUrl?: string;
|
|
224
224
|
};
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
constructor(sandboxId: string, options: SandboxTokenServerOptions) {
|
|
225
|
+
class TokenSourceDevelopmentTokenServer extends TokenSourceEndpoint {
|
|
226
|
+
constructor(tokenServerId: string, options: DevelopmentTokenServerOptions) {
|
|
228
227
|
const { baseUrl = 'https://cloud-api.livekit.io', ...rest } = options;
|
|
229
228
|
|
|
230
229
|
super(`${baseUrl}/api/v2/sandbox/connection-details`, {
|
|
231
230
|
...rest,
|
|
232
231
|
headers: {
|
|
233
|
-
'X-Sandbox-ID':
|
|
232
|
+
'X-Sandbox-ID': tokenServerId,
|
|
234
233
|
},
|
|
235
234
|
});
|
|
236
235
|
}
|
|
237
236
|
}
|
|
238
237
|
|
|
238
|
+
/** @deprecated use {@link DevelopmentTokenServerOptions} instead */
|
|
239
|
+
export type SandboxTokenServerOptions = DevelopmentTokenServerOptions;
|
|
240
|
+
|
|
241
|
+
/** @deprecated Use {@link TokenSourceDevelopmentTokenServer} instead */
|
|
242
|
+
class TokenSourceSandboxTokenServer extends TokenSourceDevelopmentTokenServer {}
|
|
243
|
+
|
|
239
244
|
export {
|
|
240
245
|
/** The return type of {@link TokenSource.literal} */
|
|
241
246
|
type TokenSourceLiteral,
|
|
@@ -243,8 +248,9 @@ export {
|
|
|
243
248
|
type TokenSourceCustom,
|
|
244
249
|
/** The return type of {@link TokenSource.endpoint} */
|
|
245
250
|
type TokenSourceEndpoint,
|
|
246
|
-
/** The return type of {@link TokenSource.sandboxTokenServer} */
|
|
247
251
|
type TokenSourceSandboxTokenServer,
|
|
252
|
+
/** The return type of {@link TokenSource.developmentTokenServer} */
|
|
253
|
+
type TokenSourceDevelopmentTokenServer,
|
|
248
254
|
decodeTokenPayload,
|
|
249
255
|
areTokenSourceFetchOptionsEqual,
|
|
250
256
|
};
|
|
@@ -269,22 +275,29 @@ export const TokenSource = {
|
|
|
269
275
|
/**
|
|
270
276
|
* TokenSource.endpoint creates a token source that fetches credentials from a given URL using
|
|
271
277
|
* the standard endpoint format:
|
|
272
|
-
* @see https://
|
|
278
|
+
* @see https://docs.livekit.io/frontends/build/authentication/endpoint/
|
|
273
279
|
*/
|
|
274
280
|
endpoint(url: string, options: EndpointOptions = {}) {
|
|
275
281
|
return new TokenSourceEndpoint(url, options);
|
|
276
282
|
},
|
|
277
283
|
|
|
278
284
|
/**
|
|
279
|
-
*
|
|
285
|
+
* @deprecated Use {@link TokenSource.developmentTokenServer} instead
|
|
286
|
+
*/
|
|
287
|
+
sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) {
|
|
288
|
+
return new TokenSourceSandboxTokenServer(sandboxId, options);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* TokenSource.developmentTokenServer queries a development token server for credentials,
|
|
280
293
|
* which supports quick prototyping / getting started types of use cases.
|
|
281
294
|
*
|
|
282
295
|
* This token provider is INSECURE and should NOT be used in production.
|
|
283
296
|
*
|
|
284
297
|
* For more info:
|
|
285
|
-
* @see https://
|
|
298
|
+
* @see https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/
|
|
286
299
|
*/
|
|
287
|
-
|
|
288
|
-
return new
|
|
300
|
+
developmentTokenServer(tokenServerId: string, options: DevelopmentTokenServerOptions = {}) {
|
|
301
|
+
return new TokenSourceDevelopmentTokenServer(tokenServerId, options);
|
|
289
302
|
},
|
|
290
303
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { videoLayersFromEncodings } from './LocalVideoTrack';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import LocalVideoTrack, { videoLayersFromEncodings } from './LocalVideoTrack';
|
|
3
|
+
import type { SimulcastTrackInfo } from './LocalVideoTrack';
|
|
3
4
|
import { VideoQuality } from './Track';
|
|
5
|
+
import type { VideoCodec } from './options';
|
|
4
6
|
|
|
5
7
|
describe('videoLayersFromEncodings', () => {
|
|
6
8
|
it('returns single layer for no encoding', () => {
|
|
@@ -131,3 +133,104 @@ describe('videoLayersFromEncodings', () => {
|
|
|
131
133
|
expect(layers[2].width).toBe(720);
|
|
132
134
|
});
|
|
133
135
|
});
|
|
136
|
+
|
|
137
|
+
function makeSender(label = 'sender', events: string[] = []) {
|
|
138
|
+
let params: RTCRtpSendParameters = {
|
|
139
|
+
encodings: [],
|
|
140
|
+
transactionId: '',
|
|
141
|
+
codecs: [],
|
|
142
|
+
headerExtensions: [],
|
|
143
|
+
rtcp: {},
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
getParameters: () => params,
|
|
147
|
+
// resolves on a later task, like the real setParameters, so that a caller
|
|
148
|
+
// which fails to await it never observes the update
|
|
149
|
+
setParameters: vi.fn(async (next: RTCRtpSendParameters) => {
|
|
150
|
+
events.push(`${label}:start`);
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
152
|
+
params = next;
|
|
153
|
+
events.push(`${label}:done`);
|
|
154
|
+
}),
|
|
155
|
+
get degradationPreference() {
|
|
156
|
+
return params.degradationPreference;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeTrack() {
|
|
162
|
+
const track = Object.create(LocalVideoTrack.prototype) as LocalVideoTrack;
|
|
163
|
+
Object.assign(track, {
|
|
164
|
+
log: { debug: vi.fn(), warn: vi.fn() },
|
|
165
|
+
simulcastCodecs: new Map<VideoCodec, SimulcastTrackInfo>(),
|
|
166
|
+
subscribedCodecs: undefined,
|
|
167
|
+
});
|
|
168
|
+
// logContext and mediaStreamTrack are getters we don't set up state for here
|
|
169
|
+
Object.defineProperty(track, 'logContext', { get: () => ({}) });
|
|
170
|
+
Object.defineProperty(track, 'mediaStreamTrack', { get: () => ({ clone: () => ({}) }) });
|
|
171
|
+
return track;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
describe('setDegradationPreference', () => {
|
|
175
|
+
it('applies the preference to the primary sender', async () => {
|
|
176
|
+
const track = makeTrack();
|
|
177
|
+
const sender = makeSender();
|
|
178
|
+
Object.assign(track, { _sender: sender });
|
|
179
|
+
|
|
180
|
+
await track.setDegradationPreference('maintain-resolution');
|
|
181
|
+
|
|
182
|
+
expect(sender.degradationPreference).toBe('maintain-resolution');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('applies the resolved preference to a backup codec sender', async () => {
|
|
186
|
+
const track = makeTrack();
|
|
187
|
+
const primary = makeSender();
|
|
188
|
+
Object.assign(track, { _sender: primary });
|
|
189
|
+
|
|
190
|
+
await track.setDegradationPreference('maintain-resolution');
|
|
191
|
+
|
|
192
|
+
// the backup codec transceiver is created later, when the server asks for it
|
|
193
|
+
const backupInfo = track.addSimulcastTrack('vp8', [])!;
|
|
194
|
+
const backup = makeSender();
|
|
195
|
+
await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);
|
|
196
|
+
|
|
197
|
+
expect(backupInfo.sender).toBe(backup);
|
|
198
|
+
expect(backup.degradationPreference).toBe('maintain-resolution');
|
|
199
|
+
expect(primary.degradationPreference).toBe('maintain-resolution');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('updates every sender when the preference changes after the backup is published', async () => {
|
|
203
|
+
const track = makeTrack();
|
|
204
|
+
const primary = makeSender();
|
|
205
|
+
Object.assign(track, { _sender: primary });
|
|
206
|
+
await track.setDegradationPreference('maintain-framerate');
|
|
207
|
+
|
|
208
|
+
track.addSimulcastTrack('vp8', []);
|
|
209
|
+
const backup = makeSender();
|
|
210
|
+
await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender);
|
|
211
|
+
|
|
212
|
+
await track.setDegradationPreference('balanced');
|
|
213
|
+
|
|
214
|
+
expect(primary.degradationPreference).toBe('balanced');
|
|
215
|
+
expect(backup.degradationPreference).toBe('balanced');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('applies to senders one at a time', async () => {
|
|
219
|
+
const events: string[] = [];
|
|
220
|
+
const track = makeTrack();
|
|
221
|
+
const primary = makeSender('primary', events);
|
|
222
|
+
Object.assign(track, { _sender: primary });
|
|
223
|
+
track.addSimulcastTrack('vp8', []);
|
|
224
|
+
await track.setSimulcastTrackSender(
|
|
225
|
+
'vp8',
|
|
226
|
+
makeSender('backup', events) as unknown as RTCRtpSender,
|
|
227
|
+
);
|
|
228
|
+
events.length = 0;
|
|
229
|
+
|
|
230
|
+
await track.setDegradationPreference('balanced');
|
|
231
|
+
|
|
232
|
+
// setParameters is only valid against the most recent getParameters, so the
|
|
233
|
+
// writes must not overlap
|
|
234
|
+
expect(events).toEqual(['primary:start', 'primary:done', 'backup:start', 'backup:done']);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -406,15 +406,37 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
406
406
|
|
|
407
407
|
async setDegradationPreference(preference: RTCDegradationPreference) {
|
|
408
408
|
this.degradationPreference = preference;
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
409
|
+
// applied one sender at a time on purpose, see applyDegradationPreference
|
|
410
|
+
await this.applyDegradationPreference(this.sender);
|
|
411
|
+
for (const sc of this.simulcastCodecs.values()) {
|
|
412
|
+
await this.applyDegradationPreference(sc.sender);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Degradation preference is a property of the sender, not of the track, so every sender
|
|
418
|
+
* publishing this track needs it applied separately. A backup codec publishes over its
|
|
419
|
+
* own sender, which would otherwise let the browser resolve a preference implicitly and
|
|
420
|
+
* diverge from the primary encoder.
|
|
421
|
+
*
|
|
422
|
+
* Callers apply this sequentially rather than concurrently: `setParameters` is only valid
|
|
423
|
+
* against the parameters most recently returned by `getParameters`, which is why this file
|
|
424
|
+
* serializes other sender parameter updates through `senderLock`.
|
|
425
|
+
*/
|
|
426
|
+
private async applyDegradationPreference(sender?: RTCRtpSender) {
|
|
427
|
+
if (!sender) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
this.log.debug(
|
|
432
|
+
`setting degradationPreference to ${this.degradationPreference}`,
|
|
433
|
+
this.logContext,
|
|
434
|
+
);
|
|
435
|
+
const params = sender.getParameters();
|
|
436
|
+
params.degradationPreference = this.degradationPreference;
|
|
437
|
+
await sender.setParameters(params);
|
|
438
|
+
} catch (e: any) {
|
|
439
|
+
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
|
|
418
440
|
}
|
|
419
441
|
}
|
|
420
442
|
|
|
@@ -436,13 +458,17 @@ export default class LocalVideoTrack extends LocalTrack<Track.Kind.Video> {
|
|
|
436
458
|
return simulcastCodecInfo;
|
|
437
459
|
}
|
|
438
460
|
|
|
439
|
-
setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
|
|
461
|
+
async setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) {
|
|
440
462
|
const simulcastCodecInfo = this.simulcastCodecs.get(codec);
|
|
441
463
|
if (!simulcastCodecInfo) {
|
|
442
464
|
return;
|
|
443
465
|
}
|
|
444
466
|
simulcastCodecInfo.sender = sender;
|
|
445
467
|
|
|
468
|
+
// the backup codec publishes over its own sender, so it needs the same degradation
|
|
469
|
+
// preference the primary sender resolved to.
|
|
470
|
+
await this.applyDegradationPreference(sender);
|
|
471
|
+
|
|
446
472
|
// browser will reenable disabled codec/layers after new codec has been published,
|
|
447
473
|
// so refresh subscribedCodecs after publish a new codec
|
|
448
474
|
setTimeout(() => {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import MockMediaStreamTrack from '../../test/MockMediaStreamTrack';
|
|
3
|
+
import { TrackEvent } from '../events';
|
|
4
|
+
import RemoteVideoTrack from './RemoteVideoTrack';
|
|
5
|
+
|
|
6
|
+
describe('RemoteTrack time sync loop', () => {
|
|
7
|
+
let track: RemoteVideoTrack;
|
|
8
|
+
let getSynchronizationSources: ReturnType<typeof vi.fn>;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// fake timers back `requestAnimationFrame`, so cancellation behaves like it does in a browser
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
// `supportsSynchronizationSources()` gates the loop on this being implemented
|
|
14
|
+
vi.stubGlobal(
|
|
15
|
+
'RTCRtpReceiver',
|
|
16
|
+
class {
|
|
17
|
+
getSynchronizationSources() {}
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
let rtpTimestamp = 0;
|
|
22
|
+
getSynchronizationSources = vi.fn(() => [{ timestamp: 1, rtpTimestamp: ++rtpTimestamp }]);
|
|
23
|
+
const receiver = { getSynchronizationSources } as unknown as RTCRtpReceiver;
|
|
24
|
+
track = new RemoteVideoTrack(new MockMediaStreamTrack(), 'sid', receiver, {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.useRealTimers();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not sample the receiver when nobody listens', () => {
|
|
33
|
+
track.startMonitor();
|
|
34
|
+
vi.advanceTimersToNextFrame();
|
|
35
|
+
|
|
36
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('starts the loop when a listener subscribes', () => {
|
|
40
|
+
track.startMonitor();
|
|
41
|
+
const listener = vi.fn();
|
|
42
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
43
|
+
vi.advanceTimersToNextFrame();
|
|
44
|
+
|
|
45
|
+
expect(listener).toHaveBeenCalledWith({ timestamp: 1, rtpTimestamp: 1 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('starts the loop for listeners that subscribed before the monitor started', () => {
|
|
49
|
+
const listener = vi.fn();
|
|
50
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
51
|
+
track.startMonitor();
|
|
52
|
+
vi.advanceTimersToNextFrame();
|
|
53
|
+
|
|
54
|
+
expect(listener).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not start a second loop when a second listener subscribes', () => {
|
|
58
|
+
track.startMonitor();
|
|
59
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
60
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
61
|
+
|
|
62
|
+
vi.advanceTimersToNextFrame();
|
|
63
|
+
|
|
64
|
+
// a second chain would sample twice per frame
|
|
65
|
+
expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not start a second loop when the monitor is started again', () => {
|
|
69
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
70
|
+
track.startMonitor();
|
|
71
|
+
track.startMonitor();
|
|
72
|
+
getSynchronizationSources.mockClear();
|
|
73
|
+
|
|
74
|
+
vi.advanceTimersToNextFrame();
|
|
75
|
+
|
|
76
|
+
expect(getSynchronizationSources).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stops the loop once the last listener unsubscribes', () => {
|
|
80
|
+
const listener = vi.fn();
|
|
81
|
+
track.startMonitor();
|
|
82
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
83
|
+
vi.advanceTimersToNextFrame();
|
|
84
|
+
|
|
85
|
+
track.off(TrackEvent.TimeSyncUpdate, listener);
|
|
86
|
+
vi.advanceTimersToNextFrame();
|
|
87
|
+
getSynchronizationSources.mockClear();
|
|
88
|
+
vi.advanceTimersToNextFrame();
|
|
89
|
+
|
|
90
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('restarts the loop when a listener subscribes again', () => {
|
|
94
|
+
const listener = vi.fn();
|
|
95
|
+
track.startMonitor();
|
|
96
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
97
|
+
track.off(TrackEvent.TimeSyncUpdate, listener);
|
|
98
|
+
vi.advanceTimersToNextFrame();
|
|
99
|
+
listener.mockClear();
|
|
100
|
+
|
|
101
|
+
track.on(TrackEvent.TimeSyncUpdate, listener);
|
|
102
|
+
vi.advanceTimersToNextFrame();
|
|
103
|
+
|
|
104
|
+
expect(listener).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('cancels a running loop when the monitor is stopped', () => {
|
|
108
|
+
track.startMonitor();
|
|
109
|
+
// the listener stays subscribed, so only the cancellation can stop the loop
|
|
110
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
111
|
+
vi.advanceTimersToNextFrame();
|
|
112
|
+
expect(getSynchronizationSources).toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
track.stopMonitor();
|
|
115
|
+
getSynchronizationSources.mockClear();
|
|
116
|
+
vi.advanceTimersToNextFrame();
|
|
117
|
+
vi.advanceTimersToNextFrame();
|
|
118
|
+
|
|
119
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('resumes the loop when the monitor is started again after a stop', () => {
|
|
123
|
+
track.startMonitor();
|
|
124
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
125
|
+
vi.advanceTimersToNextFrame();
|
|
126
|
+
track.stopMonitor();
|
|
127
|
+
getSynchronizationSources.mockClear();
|
|
128
|
+
|
|
129
|
+
track.startMonitor();
|
|
130
|
+
vi.advanceTimersToNextFrame();
|
|
131
|
+
|
|
132
|
+
expect(getSynchronizationSources).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not restart the loop after the monitor has been stopped', () => {
|
|
136
|
+
track.startMonitor();
|
|
137
|
+
track.stopMonitor();
|
|
138
|
+
|
|
139
|
+
track.on(TrackEvent.TimeSyncUpdate, vi.fn());
|
|
140
|
+
vi.advanceTimersToNextFrame();
|
|
141
|
+
|
|
142
|
+
expect(getSynchronizationSources).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EventEmitter } from 'events';
|
|
1
2
|
import { TrackEvent } from '../events';
|
|
2
3
|
import { monitorFrequency } from '../stats';
|
|
3
4
|
import type { LoggerOptions } from '../types';
|
|
@@ -123,20 +124,46 @@ export default abstract class RemoteTrack<
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/* @internal */
|
|
128
|
+
stopMonitor() {
|
|
129
|
+
super.stopMonitor();
|
|
130
|
+
(this as unknown as EventEmitter).off('newListener', this.onTimeSyncListenerAdded);
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
protected abstract monitorReceiver(): void;
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
private timeSyncLoop = () => {
|
|
136
|
+
if (this.listenerCount(TrackEvent.TimeSyncUpdate) === 0) {
|
|
137
|
+
// nobody is listening anymore, pause the loop until a new listener subscribes
|
|
138
|
+
this.timeSyncHandle = undefined;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
|
|
142
|
+
const sources = this.receiver?.getSynchronizationSources()[0];
|
|
143
|
+
if (sources) {
|
|
144
|
+
const { timestamp, rtpTimestamp } = sources;
|
|
145
|
+
if (rtpTimestamp && this.rtpTimestamp !== rtpTimestamp) {
|
|
146
|
+
this.emit(TrackEvent.TimeSyncUpdate, { timestamp, rtpTimestamp });
|
|
147
|
+
this.rtpTimestamp = rtpTimestamp;
|
|
138
148
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
private onTimeSyncListenerAdded = (event: string) => {
|
|
153
|
+
if (event === TrackEvent.TimeSyncUpdate && this.timeSyncHandle === undefined) {
|
|
154
|
+
// `newListener` fires before the listener is registered, so schedule the
|
|
155
|
+
// next frame instead of entering the loop (which would see a count of 0)
|
|
156
|
+
this.timeSyncHandle = requestAnimationFrame(this.timeSyncLoop);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
registerTimeSyncUpdate() {
|
|
161
|
+
// `newListener` isn't part of the typed event map, hence the cast
|
|
162
|
+
const emitter = this as unknown as EventEmitter;
|
|
163
|
+
emitter.off('newListener', this.onTimeSyncListenerAdded);
|
|
164
|
+
emitter.on('newListener', this.onTimeSyncListenerAdded);
|
|
165
|
+
if (this.timeSyncHandle === undefined) {
|
|
166
|
+
this.timeSyncLoop();
|
|
167
|
+
}
|
|
141
168
|
}
|
|
142
169
|
}
|
package/src/room/track/Track.ts
CHANGED
|
@@ -276,8 +276,9 @@ export abstract class Track<
|
|
|
276
276
|
if (this.monitorInterval) {
|
|
277
277
|
clearInterval(this.monitorInterval);
|
|
278
278
|
}
|
|
279
|
-
if (this.timeSyncHandle) {
|
|
279
|
+
if (this.timeSyncHandle !== undefined) {
|
|
280
280
|
cancelAnimationFrame(this.timeSyncHandle);
|
|
281
|
+
this.timeSyncHandle = undefined;
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
|
package/src/room/utils.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ClientInfo_Capability } from '@livekit/protocol';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
ddExtensionURI,
|
|
5
|
+
extractMaxAgeFromRequestHeaders,
|
|
6
|
+
getClientInfo,
|
|
7
|
+
negotiateDependencyDescriptor,
|
|
8
|
+
splitUtf8,
|
|
9
|
+
toWebsocketUrl,
|
|
10
|
+
} from './utils';
|
|
4
11
|
|
|
5
12
|
describe('toWebsocketUrl', () => {
|
|
6
13
|
it('leaves wss urls alone', () => {
|
|
@@ -188,3 +195,65 @@ describe('extractMaxAgeFromRequestHeaders', () => {
|
|
|
188
195
|
expect(extractMaxAgeFromRequestHeaders(headers)).toBe(3600);
|
|
189
196
|
});
|
|
190
197
|
});
|
|
198
|
+
|
|
199
|
+
describe('negotiateDependencyDescriptor', () => {
|
|
200
|
+
/** A transceiver whose header extension control offers `extensions`, or none at all. */
|
|
201
|
+
const transceiverWith = (extensions?: RTCRtpHeaderExtensionCapability[], throws = false) => {
|
|
202
|
+
const set = vi.fn((updated: RTCRtpHeaderExtensionCapability[]) => {
|
|
203
|
+
if (throws) {
|
|
204
|
+
throw new Error('InvalidModificationError');
|
|
205
|
+
}
|
|
206
|
+
extensions = updated;
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
transceiver: (extensions
|
|
210
|
+
? {
|
|
211
|
+
getHeaderExtensionsToNegotiate: () => extensions!,
|
|
212
|
+
setHeaderExtensionsToNegotiate: set,
|
|
213
|
+
}
|
|
214
|
+
: {}) as unknown as RTCRtpTransceiver,
|
|
215
|
+
set,
|
|
216
|
+
current: () => extensions,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
it('turns the extension on for a transceiver that has it stopped', () => {
|
|
221
|
+
const { transceiver, set, current } = transceiverWith([
|
|
222
|
+
{ uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
|
|
223
|
+
{ uri: ddExtensionURI, direction: 'stopped' },
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
|
|
227
|
+
expect(set).toHaveBeenCalledOnce();
|
|
228
|
+
// sendrecv keeps it a plain a=extmap line, with no direction suffix for the server to parse
|
|
229
|
+
expect(current()?.find((ext) => ext.uri === ddExtensionURI)?.direction).toBe('sendrecv');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('leaves an extension the browser already negotiates alone', () => {
|
|
233
|
+
const { transceiver, set } = transceiverWith([{ uri: ddExtensionURI, direction: 'recvonly' }]);
|
|
234
|
+
|
|
235
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(true);
|
|
236
|
+
expect(set).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('reports no negotiation where the browser does not know the extension', () => {
|
|
240
|
+
const { transceiver, set } = transceiverWith([
|
|
241
|
+
{ uri: 'urn:ietf:params:rtp-hdrext:sdes:mid', direction: 'sendrecv' },
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
245
|
+
expect(set).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('reports no negotiation where the browser has no such control', () => {
|
|
249
|
+
const { transceiver } = transceiverWith();
|
|
250
|
+
|
|
251
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('swallows a rejected direction rather than failing the connection', () => {
|
|
255
|
+
const { transceiver } = transceiverWith([{ uri: ddExtensionURI, direction: 'stopped' }], true);
|
|
256
|
+
|
|
257
|
+
expect(negotiateDependencyDescriptor(transceiver)).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/room/utils.ts
CHANGED
|
@@ -146,6 +146,45 @@ export function isSVCCodec(codec?: string): boolean {
|
|
|
146
146
|
return codec === 'av1' || codec === 'vp9';
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Opts `transceiver` into negotiating the AV1 dependency descriptor, reporting whether it will be.
|
|
151
|
+
*
|
|
152
|
+
* Chrome only offers the extension on transceivers that can send, so one we create to receive on
|
|
153
|
+
* never negotiates it — and Chrome 152 stopped decoding AV1 that arrives without it: frames get
|
|
154
|
+
* assembled, none ever decode, and the receiver asks for a keyframe forever. Asking through the
|
|
155
|
+
* transceiver rather than munging the extension into the SDP leaves the browser owning the
|
|
156
|
+
* extension id, which is what keeps that id consistent across the bundle and across
|
|
157
|
+
* renegotiations.
|
|
158
|
+
*
|
|
159
|
+
* A no-op where the browser offers no such control, or does not know the extension at all.
|
|
160
|
+
* @internal
|
|
161
|
+
*/
|
|
162
|
+
export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): boolean {
|
|
163
|
+
const extensions = transceiver.getHeaderExtensionsToNegotiate?.();
|
|
164
|
+
if (!extensions || !transceiver.setHeaderExtensionsToNegotiate) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const dd = extensions.find((ext) => ext.uri === ddExtensionURI);
|
|
168
|
+
if (!dd) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (dd.direction !== 'stopped') {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
// sendrecv rather than recvonly, so that the extension is written as a plain `a=extmap` line —
|
|
175
|
+
// the form the server already emits where it is the one offering — rather than one carrying a
|
|
176
|
+
// `/recvonly` suffix that its parser may not expect
|
|
177
|
+
dd.direction = 'sendrecv';
|
|
178
|
+
try {
|
|
179
|
+
transceiver.setHeaderExtensionsToNegotiate(extensions);
|
|
180
|
+
return true;
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// a rejected direction throws. Negotiating without the extension is what happened before this
|
|
183
|
+
// existed, so it is not worth failing the connection over
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
149
188
|
export function supportsSetSinkId(elm?: HTMLMediaElement): boolean {
|
|
150
189
|
if (!document || isSafariBased()) {
|
|
151
190
|
return false;
|
|
@@ -867,3 +906,16 @@ export function isPublisherOfferWithJoinSupported() {
|
|
|
867
906
|
// we have connectivity issue about publisher offer with join on firefox #1919
|
|
868
907
|
return isCompressionStreamSupported() && !isFireFox();
|
|
869
908
|
}
|
|
909
|
+
|
|
910
|
+
export function extractTrackSid(
|
|
911
|
+
mediaTrack: MediaStreamTrack,
|
|
912
|
+
stream: MediaStream,
|
|
913
|
+
): Track.SID | undefined {
|
|
914
|
+
const [, streamId] = unpackStreamId(stream.id);
|
|
915
|
+
if (streamId?.startsWith('TR')) {
|
|
916
|
+
return streamId;
|
|
917
|
+
}
|
|
918
|
+
if (mediaTrack.id.startsWith('TR')) {
|
|
919
|
+
return mediaTrack.id;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper: resolves `true` if `promise` is still pending after `ms`
|
|
3
|
+
* (default: one macrotask), `false` if it has already settled — regardless of
|
|
4
|
+
* whether it resolved or rejected.
|
|
5
|
+
*
|
|
6
|
+
* Use to assert that something has *not* happened yet (e.g. an onClose callback
|
|
7
|
+
* that must stay silent), optionally within a timeout window. Preferable to
|
|
8
|
+
* `Promise.race([p, Promise.resolve(sentinel)])` when the "not yet" needs to
|
|
9
|
+
* hold for a duration rather than just the current microtask.
|
|
10
|
+
*/
|
|
11
|
+
export async function isPending(promise: Promise<unknown>, ms = 0): Promise<boolean> {
|
|
12
|
+
const pending = Symbol('pending');
|
|
13
|
+
// Swallow settlement (incl. rejection) so racing it can't raise unhandled
|
|
14
|
+
// rejections when the timer wins.
|
|
15
|
+
const settled = promise.then(
|
|
16
|
+
() => 'settled' as const,
|
|
17
|
+
() => 'settled' as const,
|
|
18
|
+
);
|
|
19
|
+
const timer = new Promise<typeof pending>((resolve) => {
|
|
20
|
+
setTimeout(() => resolve(pending), ms);
|
|
21
|
+
});
|
|
22
|
+
return (await Promise.race([settled, timer])) === pending;
|
|
23
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type ChildProcess, execFileSync, spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { Socket, createServer } from 'node:net';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { sleep } from '../room/utils';
|
|
5
6
|
import { createToken } from './signalToken';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -47,7 +48,7 @@ async function waitReady(serverUrl: string): Promise<boolean> {
|
|
|
47
48
|
} catch {
|
|
48
49
|
// not up yet
|
|
49
50
|
}
|
|
50
|
-
await
|
|
51
|
+
await sleep(200);
|
|
51
52
|
}
|
|
52
53
|
return false;
|
|
53
54
|
}
|