@stream-io/video-client 1.58.0 → 1.59.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/index.browser.es.js +367 -76
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +378 -74
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +367 -76
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +15 -4
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/utils.d.ts +10 -0
- package/dist/src/devices/CameraManager.d.ts +2 -1
- package/dist/src/devices/DeviceManager.d.ts +1 -1
- package/dist/src/devices/MicrophoneManager.d.ts +2 -1
- package/dist/src/gen/coordinator/index.d.ts +26 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/sorting/participants.d.ts +13 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/Call.ts +156 -134
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +43 -24
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/__tests__/eventListenerIsolation.test.ts +115 -0
- package/src/coordinator/connection/client.ts +3 -2
- package/src/coordinator/connection/utils.ts +28 -0
- package/src/devices/CameraManager.ts +16 -4
- package/src/devices/DeviceManager.ts +7 -4
- package/src/devices/MicrophoneManager.ts +11 -3
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/events/call.ts +6 -3
- package/src/gen/coordinator/index.ts +26 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/loopback.ts +200 -0
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/Subscriber.ts +8 -0
- package/src/rtc/__tests__/Subscriber.test.ts +101 -0
- package/src/sorting/__tests__/presets.test.ts +188 -3
- package/src/sorting/__tests__/sorting.test.ts +22 -1
- package/src/sorting/participants.ts +20 -0
- package/src/sorting/presets.ts +4 -0
- package/src/store/__tests__/CallState.test.ts +2 -2
|
@@ -842,7 +842,10 @@ export abstract class DeviceManager<
|
|
|
842
842
|
writePreferences(currentDevice, deviceKey, muted, storageKey);
|
|
843
843
|
}
|
|
844
844
|
|
|
845
|
-
protected async applyPersistedPreferences(
|
|
845
|
+
protected async applyPersistedPreferences(
|
|
846
|
+
enabledInCallType: boolean,
|
|
847
|
+
forceDisabled = false,
|
|
848
|
+
) {
|
|
846
849
|
const deviceKey: DevicePreferenceKey =
|
|
847
850
|
this.trackType === TrackType.AUDIO ? 'microphone' : 'camera';
|
|
848
851
|
const preferences = readPreferences(this.devicePersistence.storageKey);
|
|
@@ -875,15 +878,15 @@ export abstract class DeviceManager<
|
|
|
875
878
|
|
|
876
879
|
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
877
880
|
if (typeof muted === 'boolean' && enabledInCallType && canPublish) {
|
|
878
|
-
await this.applyMutedState(muted);
|
|
881
|
+
await this.applyMutedState(muted, forceDisabled);
|
|
879
882
|
appliedMute = true;
|
|
880
883
|
}
|
|
881
884
|
|
|
882
885
|
return appliedDevice || appliedMute;
|
|
883
886
|
}
|
|
884
887
|
|
|
885
|
-
private async applyMutedState(muted: boolean) {
|
|
886
|
-
if (this.state.status !== undefined) return;
|
|
888
|
+
private async applyMutedState(muted: boolean, forceDisabled: boolean) {
|
|
889
|
+
if (forceDisabled || this.state.status !== undefined) return;
|
|
887
890
|
if (muted) {
|
|
888
891
|
await this.disable();
|
|
889
892
|
} else {
|
|
@@ -388,8 +388,13 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
388
388
|
* Applies the audio settings to the microphone.
|
|
389
389
|
* @param settings the audio settings to apply.
|
|
390
390
|
* @param publish whether to publish the stream after applying the settings.
|
|
391
|
+
* @param forceDisabled whether to keep the mic disabled, regardless of server-side or local preferences.
|
|
391
392
|
*/
|
|
392
|
-
async apply(
|
|
393
|
+
async apply(
|
|
394
|
+
settings: AudioSettingsResponse,
|
|
395
|
+
publish: boolean,
|
|
396
|
+
forceDisabled: boolean = false,
|
|
397
|
+
) {
|
|
393
398
|
// Wait for any in progress mic operation
|
|
394
399
|
await this.statusChangeSettled();
|
|
395
400
|
|
|
@@ -407,12 +412,15 @@ export class MicrophoneManager extends AudioDeviceManager<MicrophoneManagerState
|
|
|
407
412
|
this.devicePersistence.enabled &&
|
|
408
413
|
permissionState === 'granted'
|
|
409
414
|
) {
|
|
410
|
-
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
415
|
+
persistedPreferencesApplied = await this.applyPersistedPreferences(
|
|
416
|
+
true,
|
|
417
|
+
forceDisabled,
|
|
418
|
+
);
|
|
411
419
|
}
|
|
412
420
|
|
|
413
421
|
const canPublish = this.call.permissionsContext.canPublish(this.trackType);
|
|
414
422
|
if (shouldApplyDefaults && !persistedPreferencesApplied) {
|
|
415
|
-
if (canPublish && settings.mic_default_on) {
|
|
423
|
+
if (!forceDisabled && canPublish && settings.mic_default_on) {
|
|
416
424
|
await this.enable();
|
|
417
425
|
}
|
|
418
426
|
}
|
|
@@ -357,11 +357,37 @@ describe('CameraManager', () => {
|
|
|
357
357
|
true,
|
|
358
358
|
);
|
|
359
359
|
|
|
360
|
-
expect(applySpy).toHaveBeenCalledWith(true);
|
|
360
|
+
expect(applySpy).toHaveBeenCalledWith(true, false);
|
|
361
361
|
expect(selectDirectionSpy).not.toHaveBeenCalled();
|
|
362
362
|
expect(enableSpy).not.toHaveBeenCalled();
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
+
it('should apply persisted device preferences without enabling when forced disabled', async () => {
|
|
366
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
367
|
+
of('granted'),
|
|
368
|
+
);
|
|
369
|
+
const devicePersistence = { enabled: true, storageKey: '' };
|
|
370
|
+
const persistedManager = new CameraManager(call, devicePersistence);
|
|
371
|
+
const applySpy = vi
|
|
372
|
+
.spyOn(persistedManager as never, 'applyPersistedPreferences')
|
|
373
|
+
.mockResolvedValue(true);
|
|
374
|
+
const enableSpy = vi.spyOn(persistedManager, 'enable');
|
|
375
|
+
|
|
376
|
+
await persistedManager.apply(
|
|
377
|
+
fromPartial({
|
|
378
|
+
enabled: true,
|
|
379
|
+
target_resolution: { width: 640, height: 480 },
|
|
380
|
+
camera_facing: 'front',
|
|
381
|
+
camera_default_on: true,
|
|
382
|
+
}),
|
|
383
|
+
false,
|
|
384
|
+
true,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(applySpy).toHaveBeenCalledWith(true, true);
|
|
388
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
365
391
|
it('should skip persisted preferences when permission is not granted', async () => {
|
|
366
392
|
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
367
393
|
of('prompt'),
|
|
@@ -21,7 +21,7 @@ import { DeviceManagerState } from '../DeviceManagerState';
|
|
|
21
21
|
import { firstValueFrom, of } from 'rxjs';
|
|
22
22
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
23
23
|
import { PermissionsContext } from '../../permissions';
|
|
24
|
-
import { readPreferences } from '../devicePersistence';
|
|
24
|
+
import { defaultDeviceId, readPreferences } from '../devicePersistence';
|
|
25
25
|
|
|
26
26
|
vi.mock('../../reporting/ClientEventReporter', () => ({
|
|
27
27
|
ClientEventReporter: vi.fn(function () {
|
|
@@ -1010,6 +1010,54 @@ describe('Device Manager', () => {
|
|
|
1010
1010
|
expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[1].deviceId);
|
|
1011
1011
|
});
|
|
1012
1012
|
|
|
1013
|
+
it('selects the device without unmuting when forced disabled', async () => {
|
|
1014
|
+
localStorageMock.setItem(
|
|
1015
|
+
storageKey,
|
|
1016
|
+
JSON.stringify({
|
|
1017
|
+
camera: [
|
|
1018
|
+
{
|
|
1019
|
+
selectedDeviceId: mockVideoDevices[0].deviceId,
|
|
1020
|
+
selectedDeviceLabel: mockVideoDevices[0].label,
|
|
1021
|
+
muted: false,
|
|
1022
|
+
},
|
|
1023
|
+
],
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
const selectSpy = vi.spyOn(manager, 'select');
|
|
1028
|
+
const enableSpy = vi.spyOn(manager, 'enable');
|
|
1029
|
+
|
|
1030
|
+
// @ts-expect-error - private API
|
|
1031
|
+
const result = await manager.applyPersistedPreferences(true, true);
|
|
1032
|
+
|
|
1033
|
+
expect(result).toBe(true);
|
|
1034
|
+
expect(selectSpy).toHaveBeenCalledWith(mockVideoDevices[0].deviceId);
|
|
1035
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
it('retains a default-device mute preference when forced disabled', async () => {
|
|
1039
|
+
localStorageMock.setItem(
|
|
1040
|
+
storageKey,
|
|
1041
|
+
JSON.stringify({
|
|
1042
|
+
camera: [
|
|
1043
|
+
{
|
|
1044
|
+
selectedDeviceId: defaultDeviceId,
|
|
1045
|
+
selectedDeviceLabel: '',
|
|
1046
|
+
muted: false,
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
}),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
const enableSpy = vi.spyOn(manager, 'enable');
|
|
1053
|
+
|
|
1054
|
+
// @ts-expect-error - private API
|
|
1055
|
+
const result = await manager.applyPersistedPreferences(true, true);
|
|
1056
|
+
|
|
1057
|
+
expect(result).toBe(true);
|
|
1058
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1013
1061
|
it('applies muted state without selecting when default device is stored', async () => {
|
|
1014
1062
|
localStorageMock.setItem(
|
|
1015
1063
|
storageKey,
|
|
@@ -486,7 +486,29 @@ describe('MicrophoneManager', () => {
|
|
|
486
486
|
// @ts-expect-error - partial data
|
|
487
487
|
await persistedManager.apply({ mic_default_on: true }, true);
|
|
488
488
|
|
|
489
|
-
expect(applySpy).toHaveBeenCalledWith(true);
|
|
489
|
+
expect(applySpy).toHaveBeenCalledWith(true, false);
|
|
490
|
+
expect(enableSpy).not.toHaveBeenCalled();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should apply persisted device preferences without enabling when forced disabled', async () => {
|
|
494
|
+
vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue(
|
|
495
|
+
of('granted'),
|
|
496
|
+
);
|
|
497
|
+
const devicePersistence = { enabled: true, storageKey: '' };
|
|
498
|
+
const persistedManager = new MicrophoneManager(
|
|
499
|
+
call,
|
|
500
|
+
devicePersistence,
|
|
501
|
+
'disable-tracks',
|
|
502
|
+
);
|
|
503
|
+
const applySpy = vi
|
|
504
|
+
.spyOn(persistedManager as never, 'applyPersistedPreferences')
|
|
505
|
+
.mockResolvedValue(true);
|
|
506
|
+
const enableSpy = vi.spyOn(persistedManager, 'enable');
|
|
507
|
+
|
|
508
|
+
// @ts-expect-error - partial data
|
|
509
|
+
await persistedManager.apply({ mic_default_on: true }, false, true);
|
|
510
|
+
|
|
511
|
+
expect(applySpy).toHaveBeenCalledWith(true, true);
|
|
490
512
|
expect(enableSpy).not.toHaveBeenCalled();
|
|
491
513
|
});
|
|
492
514
|
|
package/src/events/call.ts
CHANGED
|
@@ -81,7 +81,6 @@ export const watchCallRejected = (call: Call) => {
|
|
|
81
81
|
*/
|
|
82
82
|
export const watchCallEnded = (call: Call) => {
|
|
83
83
|
return function onCallEnded() {
|
|
84
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
|
|
85
84
|
const { callingState } = call.state;
|
|
86
85
|
if (
|
|
87
86
|
callingState !== CallingState.IDLE &&
|
|
@@ -92,9 +91,13 @@ export const watchCallEnded = (call: Call) => {
|
|
|
92
91
|
reason: 'call.ended event received',
|
|
93
92
|
});
|
|
94
93
|
call
|
|
95
|
-
.leave({
|
|
94
|
+
.leave({
|
|
95
|
+
message: 'call.ended event received',
|
|
96
|
+
reject: false,
|
|
97
|
+
reason: 'ended',
|
|
98
|
+
})
|
|
96
99
|
.catch((err) => {
|
|
97
|
-
call.logger.error('Failed to leave call after call.ended
|
|
100
|
+
call.logger.error('Failed to leave call after call.ended', err);
|
|
98
101
|
});
|
|
99
102
|
}
|
|
100
103
|
};
|
|
@@ -8468,6 +8468,32 @@ export interface ReportByHistogramBucket {
|
|
|
8468
8468
|
*/
|
|
8469
8469
|
upper_bound?: Bound;
|
|
8470
8470
|
}
|
|
8471
|
+
/**
|
|
8472
|
+
* Reports a batch of client-side telemetry events. Each event is validated and processed independently; one invalid event does not block the rest of the batch.
|
|
8473
|
+
* @export
|
|
8474
|
+
* @interface ReportClientEventRequest
|
|
8475
|
+
*/
|
|
8476
|
+
export interface ReportClientEventRequest {
|
|
8477
|
+
/**
|
|
8478
|
+
* Client-side events to report (1-100 per request)
|
|
8479
|
+
* @type {Array<ClientEvent>}
|
|
8480
|
+
* @memberof ReportClientEventRequest
|
|
8481
|
+
*/
|
|
8482
|
+
events: Array<ClientEvent>;
|
|
8483
|
+
}
|
|
8484
|
+
/**
|
|
8485
|
+
* Response for reporting client-side telemetry events
|
|
8486
|
+
* @export
|
|
8487
|
+
* @interface ReportClientEventResponse
|
|
8488
|
+
*/
|
|
8489
|
+
export interface ReportClientEventResponse {
|
|
8490
|
+
/**
|
|
8491
|
+
* Duration of the request in milliseconds
|
|
8492
|
+
* @type {string}
|
|
8493
|
+
* @memberof ReportClientEventResponse
|
|
8494
|
+
*/
|
|
8495
|
+
duration: string;
|
|
8496
|
+
}
|
|
8471
8497
|
/**
|
|
8472
8498
|
*
|
|
8473
8499
|
* @export
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { videoLoggerSystem } from '../logger';
|
|
2
|
+
import { promiseWithResolvers } from './promise';
|
|
3
|
+
|
|
4
|
+
export type MediaStreamRecorderOptions = {
|
|
5
|
+
/** Auto-stop after this many milliseconds. */
|
|
6
|
+
maxDurationMs?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type MediaRecordingResult = {
|
|
10
|
+
blob: Blob;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Single-use `MediaRecorder` wrapper that returns an in-memory blob. */
|
|
14
|
+
export class MediaStreamRecorder {
|
|
15
|
+
private recording = promiseWithResolvers<MediaRecordingResult | null>();
|
|
16
|
+
private logger = videoLoggerSystem.getLogger('MediaStreamRecorder');
|
|
17
|
+
private chunks: Blob[] = [];
|
|
18
|
+
private timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
|
|
20
|
+
private recorder: MediaRecorder;
|
|
21
|
+
private readonly tracks: MediaStreamTrack[];
|
|
22
|
+
private readonly maxDurationMs: number | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(stream: MediaStream, options: MediaStreamRecorderOptions = {}) {
|
|
25
|
+
if (typeof MediaRecorder === 'undefined') {
|
|
26
|
+
throw new Error('MediaRecorder is not available in this environment');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.tracks = stream.getTracks();
|
|
30
|
+
if (this.tracks.length === 0) {
|
|
31
|
+
throw new Error('cannot record a MediaStream that has no tracks');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.maxDurationMs = options.maxDurationMs;
|
|
35
|
+
this.recorder = new MediaRecorder(new MediaStream(this.tracks));
|
|
36
|
+
|
|
37
|
+
this.recorder.ondataavailable = (event: BlobEvent) => {
|
|
38
|
+
if (event.data?.size) this.chunks.push(event.data);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
this.recorder.onstop = this.handleStop;
|
|
42
|
+
this.recorder.onerror = this.handleError;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Starts recording and resolves when recording stops. */
|
|
46
|
+
start = (): Promise<MediaRecordingResult | null> => {
|
|
47
|
+
if (this.isSettled()) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'MediaStreamRecorder is single-use: this instance has already ' +
|
|
50
|
+
'recorded. Construct a new one.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.recorder.state === 'inactive') {
|
|
55
|
+
this.tracks.forEach((track) =>
|
|
56
|
+
track.addEventListener('ended', this.stop),
|
|
57
|
+
);
|
|
58
|
+
if (this.maxDurationMs !== undefined) {
|
|
59
|
+
this.timeoutId = setTimeout(this.stop, this.maxDurationMs);
|
|
60
|
+
}
|
|
61
|
+
this.recorder.start();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this.recording.promise;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Stops recording early. */
|
|
68
|
+
stop = (): void => {
|
|
69
|
+
if (this.recorder.state !== 'inactive') this.recorder.stop();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
private isSettled = () =>
|
|
73
|
+
this.recording.isResolved() || this.recording.isRejected();
|
|
74
|
+
|
|
75
|
+
private detach = () => {
|
|
76
|
+
clearTimeout(this.timeoutId);
|
|
77
|
+
this.tracks.forEach((track) =>
|
|
78
|
+
track.removeEventListener('ended', this.stop),
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
private handleStop = () => {
|
|
83
|
+
this.detach();
|
|
84
|
+
|
|
85
|
+
if (this.chunks.length === 0) {
|
|
86
|
+
this.logger.warn('MediaRecorder recording produced no data');
|
|
87
|
+
this.recording.resolve(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mimeType = this.chunks[0].type || this.recorder.mimeType;
|
|
92
|
+
this.recording.resolve({
|
|
93
|
+
blob: new Blob(this.chunks, { type: mimeType }),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
private handleError = (event: Event) => {
|
|
98
|
+
if (this.isSettled()) return;
|
|
99
|
+
this.detach();
|
|
100
|
+
const error = (event as unknown as { error?: DOMException }).error;
|
|
101
|
+
this.logger.error('MediaRecorder error', error);
|
|
102
|
+
this.recording.reject(error ?? new Error('MediaRecorder failed'));
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
6
|
+
import { MediaStreamRecorder } from '../MediaStreamRecorder';
|
|
7
|
+
import { videoLoggerSystem } from '../../logger';
|
|
8
|
+
|
|
9
|
+
const createTrack = (kind: 'audio' | 'video' = 'video') => {
|
|
10
|
+
const events = new EventTarget();
|
|
11
|
+
return fromPartial<MediaStreamTrack>({
|
|
12
|
+
kind,
|
|
13
|
+
id: `${kind}-track`,
|
|
14
|
+
readyState: 'live',
|
|
15
|
+
stop: vi.fn(),
|
|
16
|
+
addEventListener: events.addEventListener.bind(events),
|
|
17
|
+
removeEventListener: events.removeEventListener.bind(events),
|
|
18
|
+
dispatchEvent: events.dispatchEvent.bind(events),
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class FakeMediaStream {
|
|
23
|
+
private tracks: MediaStreamTrack[];
|
|
24
|
+
|
|
25
|
+
constructor(tracks: MediaStreamTrack[] = []) {
|
|
26
|
+
this.tracks = [...tracks];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getTracks = () => [...this.tracks];
|
|
30
|
+
addTrack = (track: MediaStreamTrack) => this.tracks.push(track);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const createStream = (...tracks: MediaStreamTrack[]) =>
|
|
34
|
+
new FakeMediaStream(tracks) as unknown as MediaStream;
|
|
35
|
+
|
|
36
|
+
class FakeMediaRecorder {
|
|
37
|
+
static instances: FakeMediaRecorder[] = [];
|
|
38
|
+
static isTypeSupported = vi.fn(() => true);
|
|
39
|
+
|
|
40
|
+
stream: MediaStream;
|
|
41
|
+
options: MediaRecorderOptions | undefined;
|
|
42
|
+
state: 'inactive' | 'recording' | 'paused' = 'inactive';
|
|
43
|
+
mimeType: string;
|
|
44
|
+
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
|
45
|
+
onstop: (() => void) | null = null;
|
|
46
|
+
onerror: ((event: Event) => void) | null = null;
|
|
47
|
+
|
|
48
|
+
finalChunks: Blob[] = [new Blob(['payload'], { type: 'video/webm' })];
|
|
49
|
+
|
|
50
|
+
constructor(stream: MediaStream, options?: MediaRecorderOptions) {
|
|
51
|
+
this.stream = stream;
|
|
52
|
+
this.options = options;
|
|
53
|
+
this.mimeType = options?.mimeType ?? 'video/webm;codecs=vp8,opus';
|
|
54
|
+
FakeMediaRecorder.instances.push(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
start = vi.fn(() => {
|
|
58
|
+
this.state = 'recording';
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
stop = vi.fn(() => {
|
|
62
|
+
if (this.state === 'inactive') return;
|
|
63
|
+
this.state = 'inactive';
|
|
64
|
+
this.finalChunks.forEach((data) => this.emitData(data));
|
|
65
|
+
this.onstop?.();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
emitData = (data: Blob) => {
|
|
69
|
+
this.ondataavailable?.(fromPartial<BlobEvent>({ data }));
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lastRecorder = () =>
|
|
74
|
+
FakeMediaRecorder.instances[FakeMediaRecorder.instances.length - 1];
|
|
75
|
+
|
|
76
|
+
describe('MediaStreamRecorder', () => {
|
|
77
|
+
let logger: { debug: any; warn: any; error: any };
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
FakeMediaRecorder.instances = [];
|
|
81
|
+
vi.stubGlobal('MediaRecorder', FakeMediaRecorder);
|
|
82
|
+
vi.stubGlobal('MediaStream', FakeMediaStream);
|
|
83
|
+
logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
84
|
+
vi.spyOn(videoLoggerSystem, 'getLogger').mockReturnValue(
|
|
85
|
+
fromPartial(logger),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.unstubAllGlobals();
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
vi.useRealTimers();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('construction', () => {
|
|
96
|
+
it('throws when MediaRecorder is unavailable', () => {
|
|
97
|
+
vi.stubGlobal('MediaRecorder', undefined);
|
|
98
|
+
expect(
|
|
99
|
+
() => new MediaStreamRecorder(createStream(createTrack())),
|
|
100
|
+
).toThrow(/MediaRecorder is not available/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('throws when the stream has no tracks', () => {
|
|
104
|
+
expect(() => new MediaStreamRecorder(createStream())).toThrow(
|
|
105
|
+
/has no tracks/,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('lets the browser choose the recording MIME type', () => {
|
|
110
|
+
new MediaStreamRecorder(createStream(createTrack()));
|
|
111
|
+
|
|
112
|
+
expect(lastRecorder().options).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not start recording until `start` is called', () => {
|
|
116
|
+
new MediaStreamRecorder(createStream(createTrack()));
|
|
117
|
+
|
|
118
|
+
expect(lastRecorder().start).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('creates no DOM elements of its own', () => {
|
|
122
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
123
|
+
recorder.start();
|
|
124
|
+
expect(document.querySelectorAll('video, audio')).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('records a snapshot, so mutating the caller stream cannot disturb it', () => {
|
|
128
|
+
const track = createTrack();
|
|
129
|
+
const stream = createStream(track);
|
|
130
|
+
new MediaStreamRecorder(stream);
|
|
131
|
+
|
|
132
|
+
stream.addTrack(createTrack('audio'));
|
|
133
|
+
|
|
134
|
+
expect(lastRecorder().stream).not.toBe(stream);
|
|
135
|
+
expect(lastRecorder().stream.getTracks()).toEqual([track]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('recording', () => {
|
|
140
|
+
it('resolves with the recorded blob', async () => {
|
|
141
|
+
vi.useFakeTimers();
|
|
142
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
143
|
+
|
|
144
|
+
const recording = recorder.start();
|
|
145
|
+
vi.advanceTimersByTime(2500);
|
|
146
|
+
recorder.stop();
|
|
147
|
+
|
|
148
|
+
const result = await recording;
|
|
149
|
+
|
|
150
|
+
expect(result?.blob.size).toBeGreaterThan(0);
|
|
151
|
+
expect(result?.blob.type).toBe('video/webm');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('hands every awaiter of `start` the same result', async () => {
|
|
155
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
156
|
+
|
|
157
|
+
const first = recorder.start();
|
|
158
|
+
const second = recorder.start();
|
|
159
|
+
recorder.stop();
|
|
160
|
+
|
|
161
|
+
expect(await first).toBe(await second);
|
|
162
|
+
expect(lastRecorder().start).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('sets the blob type from the recorder when the chunk carries none', async () => {
|
|
166
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
167
|
+
lastRecorder().finalChunks = [new Blob(['payload'])];
|
|
168
|
+
|
|
169
|
+
const recording = recorder.start();
|
|
170
|
+
recorder.stop();
|
|
171
|
+
|
|
172
|
+
expect((await recording)?.blob.type).toBe('video/webm;codecs=vp8,opus');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('keeps chunks that arrive before stop', async () => {
|
|
176
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
177
|
+
const recording = recorder.start();
|
|
178
|
+
lastRecorder().emitData(new Blob(['early'], { type: 'video/webm' }));
|
|
179
|
+
|
|
180
|
+
recorder.stop();
|
|
181
|
+
|
|
182
|
+
expect((await recording)?.blob.size).toBe(12);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('ignores empty chunks', async () => {
|
|
186
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
187
|
+
const recording = recorder.start();
|
|
188
|
+
lastRecorder().emitData(new Blob([]));
|
|
189
|
+
lastRecorder().finalChunks = [];
|
|
190
|
+
|
|
191
|
+
recorder.stop();
|
|
192
|
+
|
|
193
|
+
await expect(recording).resolves.toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('resolves null and warns when nothing was captured', async () => {
|
|
197
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
198
|
+
lastRecorder().finalChunks = [];
|
|
199
|
+
|
|
200
|
+
const recording = recorder.start();
|
|
201
|
+
recorder.stop();
|
|
202
|
+
|
|
203
|
+
await expect(recording).resolves.toBeNull();
|
|
204
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
205
|
+
expect.stringContaining('recording produced no data'),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('never stops the tracks it was handed', async () => {
|
|
210
|
+
const track = createTrack();
|
|
211
|
+
const recorder = new MediaStreamRecorder(createStream(track));
|
|
212
|
+
|
|
213
|
+
const recording = recorder.start();
|
|
214
|
+
recorder.stop();
|
|
215
|
+
await recording;
|
|
216
|
+
|
|
217
|
+
expect(track.stop).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('auto-stop', () => {
|
|
222
|
+
it('stops once maxDurationMs elapses', async () => {
|
|
223
|
+
vi.useFakeTimers();
|
|
224
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()), {
|
|
225
|
+
maxDurationMs: 5000,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const recording = recorder.start();
|
|
229
|
+
vi.advanceTimersByTime(5000);
|
|
230
|
+
|
|
231
|
+
await expect(recording).resolves.toMatchObject({
|
|
232
|
+
blob: expect.any(Blob),
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('runs indefinitely when maxDurationMs is omitted', async () => {
|
|
237
|
+
vi.useFakeTimers();
|
|
238
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
239
|
+
|
|
240
|
+
const recording = recorder.start();
|
|
241
|
+
vi.advanceTimersByTime(10 * 60 * 1000);
|
|
242
|
+
|
|
243
|
+
expect(lastRecorder().stop).not.toHaveBeenCalled();
|
|
244
|
+
recorder.stop();
|
|
245
|
+
await recording;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('stops when any source track ends, keeping what was captured', async () => {
|
|
249
|
+
const audioTrack = createTrack('audio');
|
|
250
|
+
const recorder = new MediaStreamRecorder(
|
|
251
|
+
createStream(createTrack(), audioTrack),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const recording = recorder.start();
|
|
255
|
+
audioTrack.dispatchEvent(new Event('ended'));
|
|
256
|
+
|
|
257
|
+
await expect(recording).resolves.toMatchObject({
|
|
258
|
+
blob: expect.any(Blob),
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('resolves when the user agent stops the recorder on its own', async () => {
|
|
263
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
264
|
+
const recording = recorder.start();
|
|
265
|
+
|
|
266
|
+
lastRecorder().stop();
|
|
267
|
+
|
|
268
|
+
await expect(recording).resolves.toMatchObject({
|
|
269
|
+
blob: expect.any(Blob),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('lifecycle guards', () => {
|
|
275
|
+
it('is a no-op when stopped before it was started', () => {
|
|
276
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
277
|
+
|
|
278
|
+
expect(() => recorder.stop()).not.toThrow();
|
|
279
|
+
expect(lastRecorder().stop).not.toHaveBeenCalled();
|
|
280
|
+
expect(lastRecorder().start).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('throws when restarted after finishing', async () => {
|
|
284
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
285
|
+
const recording = recorder.start();
|
|
286
|
+
recorder.stop();
|
|
287
|
+
await recording;
|
|
288
|
+
|
|
289
|
+
expect(() => recorder.start()).toThrow(/single-use/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('is idempotent across repeated stops', async () => {
|
|
293
|
+
const recorder = new MediaStreamRecorder(createStream(createTrack()));
|
|
294
|
+
const recording = recorder.start();
|
|
295
|
+
|
|
296
|
+
recorder.stop();
|
|
297
|
+
recorder.stop();
|
|
298
|
+
|
|
299
|
+
await expect(recording).resolves.toMatchObject({
|
|
300
|
+
blob: expect.any(Blob),
|
|
301
|
+
});
|
|
302
|
+
expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('leaves no timer or listener behind after finishing', async () => {
|
|
307
|
+
vi.useFakeTimers();
|
|
308
|
+
const track = createTrack();
|
|
309
|
+
const recorder = new MediaStreamRecorder(createStream(track), {
|
|
310
|
+
maxDurationMs: 5000,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const recording = recorder.start();
|
|
314
|
+
recorder.stop();
|
|
315
|
+
const result = await recording;
|
|
316
|
+
|
|
317
|
+
expect(() => {
|
|
318
|
+
vi.advanceTimersByTime(10_000);
|
|
319
|
+
track.dispatchEvent(new Event('ended'));
|
|
320
|
+
}).not.toThrow();
|
|
321
|
+
expect(lastRecorder().stop).toHaveBeenCalledTimes(1);
|
|
322
|
+
expect(result?.blob).toBeInstanceOf(Blob);
|
|
323
|
+
});
|
|
324
|
+
});
|