@stream-io/video-client 1.56.0 → 1.58.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 +23 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +867 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +911 -70
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +900 -71
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +52 -6
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +6 -0
- package/dist/src/devices/DeviceManager.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +6 -0
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1363 -243
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +3 -0
- package/package.json +11 -11
- package/src/Call.ts +160 -26
- package/src/__tests__/Call.test.ts +15 -0
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +51 -4
- package/src/devices/DeviceManager.ts +17 -0
- package/src/devices/MicrophoneManager.ts +55 -5
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/devices/devicePersistence.ts +1 -2
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1384 -244
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +2 -8
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +5 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +89 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +3 -2
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +34 -3
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +24 -9
|
@@ -40,6 +40,14 @@ export interface CallState {
|
|
|
40
40
|
* @generated from protobuf field: repeated stream.video.sfu.models.Pin pins = 4;
|
|
41
41
|
*/
|
|
42
42
|
pins: Pin[];
|
|
43
|
+
/**
|
|
44
|
+
* e2ee_enabled is true when the call uses end-to-end encryption. Clients
|
|
45
|
+
* must enable their frame encryptor; the SFU forwards encrypted frames
|
|
46
|
+
* opaquely and server-side recording/transcription/broadcasting are disabled.
|
|
47
|
+
*
|
|
48
|
+
* @generated from protobuf field: bool e2ee_enabled = 5 [json_name = "e2eeEnabled"];
|
|
49
|
+
*/
|
|
50
|
+
e2EeEnabled: boolean;
|
|
43
51
|
}
|
|
44
52
|
/**
|
|
45
53
|
* @generated from protobuf message stream.video.sfu.models.ParticipantCount
|
|
@@ -1401,6 +1409,13 @@ class CallState$Type extends MessageType<CallState> {
|
|
|
1401
1409
|
repeat: 2 /*RepeatType.UNPACKED*/,
|
|
1402
1410
|
T: () => Pin,
|
|
1403
1411
|
},
|
|
1412
|
+
{
|
|
1413
|
+
no: 5,
|
|
1414
|
+
name: 'e2ee_enabled',
|
|
1415
|
+
kind: 'scalar',
|
|
1416
|
+
jsonName: 'e2eeEnabled',
|
|
1417
|
+
T: 8 /*ScalarType.BOOL*/,
|
|
1418
|
+
},
|
|
1404
1419
|
]);
|
|
1405
1420
|
}
|
|
1406
1421
|
}
|
|
@@ -23,8 +23,10 @@ export class MediaPlaybackWatchdog {
|
|
|
23
23
|
private tracer: Tracer;
|
|
24
24
|
private controller = new AbortController();
|
|
25
25
|
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
private settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
26
27
|
private attempt = 0;
|
|
27
28
|
private disposed = false;
|
|
29
|
+
private stopped = false;
|
|
28
30
|
|
|
29
31
|
constructor(opts: MediaPlaybackWatchdogOptions) {
|
|
30
32
|
this.element = opts.element;
|
|
@@ -46,33 +48,47 @@ export class MediaPlaybackWatchdog {
|
|
|
46
48
|
if (this.disposed) return;
|
|
47
49
|
this.disposed = true;
|
|
48
50
|
this.controller.abort();
|
|
51
|
+
this.clearTimers();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
private clearTimers = () => {
|
|
49
55
|
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
50
56
|
this.pendingTimer = undefined;
|
|
57
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
58
|
+
this.settleTimer = undefined;
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
private onPlaying = () => {
|
|
54
|
-
if (this.attempt > 0) {
|
|
55
|
-
this.tracer.trace('mediaPlayback.recover.success', {
|
|
56
|
-
kind: this.kind,
|
|
57
|
-
attempts: this.attempt,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
this.attempt = 0;
|
|
61
62
|
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
62
63
|
this.pendingTimer = undefined;
|
|
64
|
+
if (this.attempt === 0 || this.settleTimer) return;
|
|
65
|
+
this.settleTimer ??= setTimeout(this.onSettled, 1000);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
private onSettled = () => {
|
|
69
|
+
this.settleTimer = undefined;
|
|
70
|
+
if (this.element.paused) return;
|
|
71
|
+
this.tracer.trace('mediaPlayback.recover.success', {
|
|
72
|
+
kind: this.kind,
|
|
73
|
+
attempts: this.attempt,
|
|
74
|
+
});
|
|
75
|
+
this.attempt = 0;
|
|
76
|
+
this.stopped = false;
|
|
63
77
|
};
|
|
64
78
|
|
|
65
79
|
private onPauseOrSuspend = (event: Event) => {
|
|
66
|
-
if (this.disposed) return;
|
|
80
|
+
if (this.disposed || this.stopped) return;
|
|
67
81
|
this.tracer.trace('mediaPlayback.paused', {
|
|
68
82
|
kind: this.kind,
|
|
69
83
|
reason: event.type,
|
|
70
84
|
});
|
|
85
|
+
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
86
|
+
this.settleTimer = undefined;
|
|
71
87
|
this.scheduleRecovery();
|
|
72
88
|
};
|
|
73
89
|
|
|
74
90
|
private scheduleRecovery = () => {
|
|
75
|
-
if (this.disposed || this.pendingTimer) return;
|
|
91
|
+
if (this.disposed || this.stopped || this.pendingTimer) return;
|
|
76
92
|
const skipReason = this.computeSkipReason();
|
|
77
93
|
if (skipReason) {
|
|
78
94
|
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
@@ -81,6 +97,16 @@ export class MediaPlaybackWatchdog {
|
|
|
81
97
|
});
|
|
82
98
|
return;
|
|
83
99
|
}
|
|
100
|
+
|
|
101
|
+
if (this.attempt >= 10) {
|
|
102
|
+
this.stopped = true;
|
|
103
|
+
this.clearTimers();
|
|
104
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
105
|
+
kind: this.kind,
|
|
106
|
+
attempts: this.attempt,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
84
110
|
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
85
111
|
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
86
112
|
};
|
|
@@ -109,13 +135,6 @@ export class MediaPlaybackWatchdog {
|
|
|
109
135
|
} catch (err) {
|
|
110
136
|
if (this.disposed) return;
|
|
111
137
|
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
112
|
-
if (this.attempt >= 10) {
|
|
113
|
-
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
114
|
-
kind: this.kind,
|
|
115
|
-
attempts: this.attempt,
|
|
116
|
-
});
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
138
|
this.scheduleRecovery();
|
|
120
139
|
}
|
|
121
140
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ScopedLogger, videoLoggerSystem } from '../logger';
|
|
2
|
+
|
|
3
|
+
export type EventMap = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export type Listener<P> = (payload: P) => void | Promise<void>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tiny, type-safe event emitter.
|
|
9
|
+
*
|
|
10
|
+
* Usage styles:
|
|
11
|
+
* - compose: `private readonly events = new TypedEventEmitter<MyMap>()`
|
|
12
|
+
* - extend: `class Foo extends TypedEventEmitter<MyMap> {}`
|
|
13
|
+
*
|
|
14
|
+
* Listener exceptions (sync throw or rejected promise) are caught and logged,
|
|
15
|
+
* so one bad listener cannot break dispatch for the rest.
|
|
16
|
+
*/
|
|
17
|
+
export class TypedEventEmitter<M extends EventMap> {
|
|
18
|
+
protected readonly logger: ScopedLogger;
|
|
19
|
+
private readonly byEvent = new Map<keyof M, Set<Listener<any>>>();
|
|
20
|
+
|
|
21
|
+
constructor(logger: ScopedLogger | string = 'TypedEventEmitter') {
|
|
22
|
+
this.logger =
|
|
23
|
+
typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
on = <E extends keyof M>(event: E, fn: Listener<M[E]>): (() => void) => {
|
|
27
|
+
let listeners = this.byEvent.get(event);
|
|
28
|
+
if (!listeners) {
|
|
29
|
+
listeners = new Set();
|
|
30
|
+
this.byEvent.set(event, listeners);
|
|
31
|
+
}
|
|
32
|
+
listeners.add(fn as Listener<any>);
|
|
33
|
+
return () => this.off(event, fn);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
off = <E extends keyof M>(event: E, fn: Listener<M[E]>): void => {
|
|
37
|
+
const listeners = this.byEvent.get(event);
|
|
38
|
+
if (!listeners) return;
|
|
39
|
+
listeners.delete(fn as Listener<any>);
|
|
40
|
+
if (listeners.size === 0) this.byEvent.delete(event);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
emit = <E extends keyof M>(event: E, payload: M[E]): void => {
|
|
44
|
+
const listeners = this.byEvent.get(event);
|
|
45
|
+
if (!listeners || listeners.size === 0) return;
|
|
46
|
+
for (const listener of [...listeners]) {
|
|
47
|
+
this.invoke(() => listener(payload), event);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
removeAllListeners = (event?: keyof M): void => {
|
|
52
|
+
if (event === undefined) {
|
|
53
|
+
this.byEvent.clear();
|
|
54
|
+
} else {
|
|
55
|
+
this.byEvent.delete(event);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
private invoke = (run: () => void | Promise<void>, event: keyof M): void => {
|
|
60
|
+
try {
|
|
61
|
+
const result = run();
|
|
62
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
63
|
+
(result as Promise<void>).catch((err) => {
|
|
64
|
+
this.logger.warn(`Listener for '${String(event)}' rejected`, err);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
this.logger.warn(`Listener for '${String(event)}' threw`, err);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -28,20 +28,33 @@ const createMediaElement = (
|
|
|
28
28
|
state: FakeMediaState = {},
|
|
29
29
|
) => {
|
|
30
30
|
const el = document.createElement(kind) as HTMLMediaElement;
|
|
31
|
-
Object.defineProperty(el, 'srcObject', {
|
|
31
|
+
Object.defineProperty(el, 'srcObject', {
|
|
32
|
+
value: 'srcObject' in state ? state.srcObject : new MediaStream(),
|
|
33
|
+
writable: true,
|
|
34
|
+
});
|
|
32
35
|
Object.defineProperty(el, 'paused', { writable: true, configurable: true });
|
|
33
36
|
Object.defineProperty(el, 'readyState', {
|
|
37
|
+
value: state.readyState ?? 4,
|
|
38
|
+
writable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
Object.defineProperty(el, 'ended', {
|
|
42
|
+
value: state.ended ?? false,
|
|
34
43
|
writable: true,
|
|
35
44
|
configurable: true,
|
|
36
45
|
});
|
|
37
|
-
|
|
38
|
-
el.srcObject = 'srcObject' in state ? state.srcObject : new MediaStream();
|
|
39
|
-
el.paused = state.paused ?? true;
|
|
40
|
-
el.readyState = state.readyState ?? 4;
|
|
41
|
-
el.ended = state.ended ?? false;
|
|
46
|
+
setPaused(el, state.paused ?? true);
|
|
42
47
|
return el;
|
|
43
48
|
};
|
|
44
49
|
|
|
50
|
+
const setPaused = (el: HTMLMediaElement, paused: boolean) => {
|
|
51
|
+
Object.defineProperty(el, 'paused', {
|
|
52
|
+
value: paused,
|
|
53
|
+
writable: true,
|
|
54
|
+
configurable: true,
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
45
58
|
type SetupOpts = {
|
|
46
59
|
kind?: 'audio' | 'video';
|
|
47
60
|
state?: FakeMediaState;
|
|
@@ -111,15 +124,131 @@ describe('MediaPlaybackWatchdog', () => {
|
|
|
111
124
|
await vi.advanceTimersByTimeAsync(6000);
|
|
112
125
|
expect(play).toHaveBeenCalledTimes(2);
|
|
113
126
|
|
|
114
|
-
// Simulate the element actually starting to play
|
|
127
|
+
// Simulate the element actually starting to play, and staying up long
|
|
128
|
+
// enough for the recovery to count as settled.
|
|
129
|
+
setPaused(el, false);
|
|
115
130
|
el.dispatchEvent(new Event('playing'));
|
|
131
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
116
132
|
|
|
117
|
-
// A subsequent pause should
|
|
133
|
+
// A subsequent pause should trigger a fresh, immediate recovery attempt.
|
|
134
|
+
setPaused(el, true);
|
|
118
135
|
el.dispatchEvent(new Event('pause'));
|
|
119
136
|
await vi.advanceTimersByTimeAsync(0);
|
|
120
137
|
expect(play).toHaveBeenCalledTimes(3);
|
|
121
138
|
});
|
|
122
139
|
|
|
140
|
+
it('backs off instead of retrying immediately when playback has not settled', async () => {
|
|
141
|
+
el.dispatchEvent(new Event('pause'));
|
|
142
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
143
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
144
|
+
|
|
145
|
+
// resumed, but re-paused before the settle delay elapsed
|
|
146
|
+
setPaused(el, false);
|
|
147
|
+
el.dispatchEvent(new Event('playing'));
|
|
148
|
+
expect(tracer.trace).not.toHaveBeenCalledWith(
|
|
149
|
+
'mediaPlayback.recover.success',
|
|
150
|
+
{
|
|
151
|
+
kind: 'audio',
|
|
152
|
+
attempts: 1,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
156
|
+
setPaused(el, true);
|
|
157
|
+
el.dispatchEvent(new Event('pause'));
|
|
158
|
+
|
|
159
|
+
// the next attempt is subject to backoff, not scheduled on the next tick
|
|
160
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
161
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
162
|
+
|
|
163
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
164
|
+
expect(play).toHaveBeenCalledTimes(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('stops fighting an element that re-pauses after every successful play', async () => {
|
|
168
|
+
setup({ kind: 'video' });
|
|
169
|
+
// Safari re-pauses the element right after play() resolves. This used to
|
|
170
|
+
// reset the attempt counter, so the loop spun with a zero delay forever.
|
|
171
|
+
play.mockImplementation(async () => {
|
|
172
|
+
setPaused(el, false);
|
|
173
|
+
el.dispatchEvent(new Event('playing'));
|
|
174
|
+
setPaused(el, true);
|
|
175
|
+
el.dispatchEvent(new Event('pause'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
el.dispatchEvent(new Event('pause'));
|
|
179
|
+
for (let i = 0; i < 20; i++) {
|
|
180
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
expect(play).toHaveBeenCalledTimes(10);
|
|
184
|
+
expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.giveUp', {
|
|
185
|
+
kind: 'video',
|
|
186
|
+
attempts: 10,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// External playback can still emit `playing` after the watchdog gave up
|
|
190
|
+
// (for example from a user gesture or another playback helper). If it
|
|
191
|
+
// re-pauses before settling, it must not revive the watchdog or trace a
|
|
192
|
+
// false recovery success.
|
|
193
|
+
setPaused(el, false);
|
|
194
|
+
el.dispatchEvent(new Event('playing'));
|
|
195
|
+
setPaused(el, true);
|
|
196
|
+
el.dispatchEvent(new Event('pause'));
|
|
197
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
198
|
+
|
|
199
|
+
expect(tracer.trace).not.toHaveBeenCalledWith(
|
|
200
|
+
'mediaPlayback.recover.success',
|
|
201
|
+
{
|
|
202
|
+
kind: 'video',
|
|
203
|
+
attempts: 10,
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// and it stays stopped, without tracing the pause storm
|
|
208
|
+
const traceMock = tracer.trace as unknown as MockInstance;
|
|
209
|
+
const tracesBefore = traceMock.mock.calls.length;
|
|
210
|
+
for (let i = 0; i < 100; i++) {
|
|
211
|
+
el.dispatchEvent(new Event('pause'));
|
|
212
|
+
}
|
|
213
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
214
|
+
|
|
215
|
+
expect(play).toHaveBeenCalledTimes(10);
|
|
216
|
+
expect(traceMock.mock.calls.length).toBe(tracesBefore);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('revives once playback genuinely holds for the settle window', async () => {
|
|
220
|
+
setup({ kind: 'video' });
|
|
221
|
+
play.mockImplementation(async () => {
|
|
222
|
+
setPaused(el, false);
|
|
223
|
+
el.dispatchEvent(new Event('playing'));
|
|
224
|
+
setPaused(el, true);
|
|
225
|
+
el.dispatchEvent(new Event('pause'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
el.dispatchEvent(new Event('pause'));
|
|
229
|
+
for (let i = 0; i < 20; i++) {
|
|
230
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
231
|
+
}
|
|
232
|
+
expect(play).toHaveBeenCalledTimes(10); // gave up
|
|
233
|
+
|
|
234
|
+
play.mockResolvedValue(undefined);
|
|
235
|
+
setPaused(el, false);
|
|
236
|
+
el.dispatchEvent(new Event('playing'));
|
|
237
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
238
|
+
|
|
239
|
+
expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.success', {
|
|
240
|
+
kind: 'video',
|
|
241
|
+
attempts: 10,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// recovery is live again: the attempt counter was reset, so a fresh pause
|
|
245
|
+
// gets an immediate attempt rather than being ignored
|
|
246
|
+
setPaused(el, true);
|
|
247
|
+
el.dispatchEvent(new Event('pause'));
|
|
248
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
249
|
+
expect(play).toHaveBeenCalledTimes(11);
|
|
250
|
+
});
|
|
251
|
+
|
|
123
252
|
it('does not attempt recovery when srcObject is null', async () => {
|
|
124
253
|
setup({ state: { srcObject: null } });
|
|
125
254
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TypedEventEmitter } from '../TypedEventEmitter';
|
|
3
|
+
|
|
4
|
+
type TestEvents = {
|
|
5
|
+
hello: string;
|
|
6
|
+
ping: { n: number };
|
|
7
|
+
empty: undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('TypedEventEmitter', () => {
|
|
11
|
+
let emitter: TypedEventEmitter<TestEvents>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
emitter = new TypedEventEmitter<TestEvents>();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('invokes listeners registered with on()', () => {
|
|
18
|
+
const listener = vi.fn();
|
|
19
|
+
emitter.on('hello', listener);
|
|
20
|
+
emitter.emit('hello', 'world');
|
|
21
|
+
expect(listener).toHaveBeenCalledWith('world');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns an unsubscribe function from on()', () => {
|
|
25
|
+
const listener = vi.fn();
|
|
26
|
+
const unsubscribe = emitter.on('hello', listener);
|
|
27
|
+
unsubscribe();
|
|
28
|
+
emitter.emit('hello', 'world');
|
|
29
|
+
expect(listener).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('removes listeners via off()', () => {
|
|
33
|
+
const listener = vi.fn();
|
|
34
|
+
emitter.on('hello', listener);
|
|
35
|
+
emitter.off('hello', listener);
|
|
36
|
+
emitter.emit('hello', 'world');
|
|
37
|
+
expect(listener).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('supports multiple listeners for the same event', () => {
|
|
41
|
+
const a = vi.fn();
|
|
42
|
+
const b = vi.fn();
|
|
43
|
+
emitter.on('ping', a);
|
|
44
|
+
emitter.on('ping', b);
|
|
45
|
+
emitter.emit('ping', { n: 1 });
|
|
46
|
+
expect(a).toHaveBeenCalledWith({ n: 1 });
|
|
47
|
+
expect(b).toHaveBeenCalledWith({ n: 1 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('deduplicates identical listener references', () => {
|
|
51
|
+
const listener = vi.fn();
|
|
52
|
+
emitter.on('hello', listener);
|
|
53
|
+
emitter.on('hello', listener);
|
|
54
|
+
emitter.emit('hello', 'once');
|
|
55
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('isolates a throwing listener from subsequent listeners', () => {
|
|
59
|
+
const bad = vi.fn(() => {
|
|
60
|
+
throw new Error('boom');
|
|
61
|
+
});
|
|
62
|
+
const good = vi.fn();
|
|
63
|
+
emitter.on('hello', bad);
|
|
64
|
+
emitter.on('hello', good);
|
|
65
|
+
expect(() => emitter.emit('hello', 'world')).not.toThrow();
|
|
66
|
+
expect(bad).toHaveBeenCalled();
|
|
67
|
+
expect(good).toHaveBeenCalledWith('world');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('isolates a rejecting async listener', async () => {
|
|
71
|
+
const rejected = vi.fn(async () => {
|
|
72
|
+
throw new Error('async boom');
|
|
73
|
+
});
|
|
74
|
+
const good = vi.fn();
|
|
75
|
+
emitter.on('hello', rejected);
|
|
76
|
+
emitter.on('hello', good);
|
|
77
|
+
expect(() => emitter.emit('hello', 'world')).not.toThrow();
|
|
78
|
+
expect(good).toHaveBeenCalled();
|
|
79
|
+
// allow microtask to settle; rejection should have been swallowed by the emitter
|
|
80
|
+
await Promise.resolve();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('is safe to call off() from within a listener (current emit still fires siblings)', () => {
|
|
84
|
+
const sibling = vi.fn();
|
|
85
|
+
const self = vi.fn(() => {
|
|
86
|
+
emitter.off('hello', self);
|
|
87
|
+
});
|
|
88
|
+
emitter.on('hello', self);
|
|
89
|
+
emitter.on('hello', sibling);
|
|
90
|
+
emitter.emit('hello', 'world');
|
|
91
|
+
expect(self).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(sibling).toHaveBeenCalledTimes(1);
|
|
93
|
+
// second emit should not invoke self again
|
|
94
|
+
emitter.emit('hello', 'again');
|
|
95
|
+
expect(self).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(sibling).toHaveBeenCalledTimes(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('is safe to add a listener from within a listener (added listener does not fire in current emit)', () => {
|
|
100
|
+
const later = vi.fn();
|
|
101
|
+
emitter.on('hello', () => {
|
|
102
|
+
emitter.on('hello', later);
|
|
103
|
+
});
|
|
104
|
+
emitter.emit('hello', 'first');
|
|
105
|
+
expect(later).not.toHaveBeenCalled();
|
|
106
|
+
emitter.emit('hello', 'second');
|
|
107
|
+
expect(later).toHaveBeenCalledWith('second');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('removeAllListeners() clears every subscription when called without args', () => {
|
|
111
|
+
const a = vi.fn();
|
|
112
|
+
const b = vi.fn();
|
|
113
|
+
emitter.on('hello', a);
|
|
114
|
+
emitter.on('ping', b);
|
|
115
|
+
emitter.removeAllListeners();
|
|
116
|
+
emitter.emit('hello', 'world');
|
|
117
|
+
emitter.emit('ping', { n: 1 });
|
|
118
|
+
expect(a).not.toHaveBeenCalled();
|
|
119
|
+
expect(b).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('removeAllListeners(event) clears only that event', () => {
|
|
123
|
+
const a = vi.fn();
|
|
124
|
+
const b = vi.fn();
|
|
125
|
+
emitter.on('hello', a);
|
|
126
|
+
emitter.on('ping', b);
|
|
127
|
+
emitter.removeAllListeners('hello');
|
|
128
|
+
emitter.emit('hello', 'world');
|
|
129
|
+
emitter.emit('ping', { n: 1 });
|
|
130
|
+
expect(a).not.toHaveBeenCalled();
|
|
131
|
+
expect(b).toHaveBeenCalledWith({ n: 1 });
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -38,8 +38,7 @@ describe('ViewportTracker', () => {
|
|
|
38
38
|
|
|
39
39
|
it('updates participant viewportVisibilityState as visibility changes', () => {
|
|
40
40
|
let visibilityHandler:
|
|
41
|
-
|
|
42
|
-
| undefined;
|
|
41
|
+
((entry: IntersectionObserverEntry) => void) | undefined;
|
|
43
42
|
vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
|
|
44
43
|
visibilityHandler = handler;
|
|
45
44
|
return vi.fn();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { videoLoggerSystem } from '../logger';
|
|
2
2
|
|
|
3
|
-
export const ensureExhausted = (x: never, message
|
|
4
|
-
|
|
3
|
+
export const ensureExhausted = (x: never, message?: string) => {
|
|
4
|
+
if (message) {
|
|
5
|
+
videoLoggerSystem.getLogger('helpers').warn(message, x);
|
|
6
|
+
}
|
|
5
7
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CallSettingsResponse,
|
|
3
|
+
OwnCapability,
|
|
4
|
+
RequestPermissionRequestPermissionsEnum,
|
|
5
|
+
} from '../gen/coordinator';
|
|
2
6
|
import { TrackType } from '../gen/video/sfu/models/models';
|
|
3
7
|
import { ensureExhausted } from '../helpers/ensureExhausted';
|
|
4
8
|
|
|
@@ -70,20 +74,21 @@ export class PermissionsContext {
|
|
|
70
74
|
* @param settings the call settings to check against (optional).
|
|
71
75
|
*/
|
|
72
76
|
canRequest = (
|
|
73
|
-
permission:
|
|
77
|
+
permission: RequestPermissionRequestPermissionsEnum,
|
|
74
78
|
settings: CallSettingsResponse | undefined = this.settings,
|
|
75
79
|
) => {
|
|
76
80
|
if (!settings) return false;
|
|
77
81
|
|
|
78
82
|
const { audio, video, screensharing } = settings;
|
|
79
83
|
switch (permission) {
|
|
80
|
-
case
|
|
84
|
+
case RequestPermissionRequestPermissionsEnum.SEND_AUDIO:
|
|
81
85
|
return audio.access_request_enabled;
|
|
82
|
-
case
|
|
86
|
+
case RequestPermissionRequestPermissionsEnum.SEND_VIDEO:
|
|
83
87
|
return video.access_request_enabled;
|
|
84
|
-
case
|
|
88
|
+
case RequestPermissionRequestPermissionsEnum.SCREENSHARE:
|
|
85
89
|
return screensharing.access_request_enabled;
|
|
86
90
|
default:
|
|
91
|
+
ensureExhausted(permission);
|
|
87
92
|
return false;
|
|
88
93
|
}
|
|
89
94
|
};
|
|
@@ -33,18 +33,12 @@ export type ClientEventStage =
|
|
|
33
33
|
| 'FirstAudioFrame';
|
|
34
34
|
|
|
35
35
|
export type MediaPermissionState =
|
|
36
|
-
| '
|
|
37
|
-
| 'FAILED'
|
|
38
|
-
| 'GRANTED'
|
|
39
|
-
| 'NOT_INITIATED';
|
|
36
|
+
'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
|
|
40
37
|
|
|
41
38
|
export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
|
|
42
39
|
|
|
43
40
|
export type JoinReason =
|
|
44
|
-
|
|
45
|
-
| 'network-available'
|
|
46
|
-
| 'migration'
|
|
47
|
-
| 'full-rejoin';
|
|
41
|
+
'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
|
|
48
42
|
|
|
49
43
|
export type ClientEventStandardCode =
|
|
50
44
|
| 'CLIENT_ABORTED'
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
TrackType,
|
|
9
9
|
WebsocketReconnectStrategy,
|
|
10
10
|
} from '../gen/video/sfu/models/models';
|
|
11
|
+
import type { E2EEManager } from './e2ee/E2EEManager';
|
|
12
|
+
import { hasInsertableStreams } from './e2ee/transformSupport';
|
|
11
13
|
import { NegotiationError } from './NegotiationError';
|
|
12
14
|
import { StreamSfuClient } from '../StreamSfuClient';
|
|
13
15
|
import { AllSfuEvents, Dispatcher } from './Dispatcher';
|
|
@@ -35,6 +37,7 @@ export abstract class BasePeerConnection {
|
|
|
35
37
|
protected readonly state: CallState;
|
|
36
38
|
protected readonly dispatcher: Dispatcher;
|
|
37
39
|
protected readonly clientPublishOptions?: ClientPublishOptions;
|
|
40
|
+
protected readonly e2ee?: E2EEManager;
|
|
38
41
|
protected tag: string;
|
|
39
42
|
protected sfuClient: StreamSfuClient;
|
|
40
43
|
|
|
@@ -75,6 +78,7 @@ export abstract class BasePeerConnection {
|
|
|
75
78
|
tag,
|
|
76
79
|
enableTracing,
|
|
77
80
|
clientPublishOptions,
|
|
81
|
+
e2ee,
|
|
78
82
|
iceRestartDelay = 2500,
|
|
79
83
|
statsTimestampDriftThresholdMs = 0,
|
|
80
84
|
}: BasePeerConnectionOpts,
|
|
@@ -85,6 +89,7 @@ export abstract class BasePeerConnection {
|
|
|
85
89
|
this.dispatcher = dispatcher;
|
|
86
90
|
this.iceRestartDelay = iceRestartDelay;
|
|
87
91
|
this.clientPublishOptions = clientPublishOptions;
|
|
92
|
+
this.e2ee = e2ee;
|
|
88
93
|
this.tag = tag;
|
|
89
94
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
90
95
|
this.onIceConnected = onIceConnected;
|
|
@@ -114,7 +119,13 @@ export abstract class BasePeerConnection {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
|
|
117
|
-
const
|
|
122
|
+
const config: RTCConfiguration = { ...connectionConfig };
|
|
123
|
+
// The legacy Insertable Streams path requires this non-standard flag.
|
|
124
|
+
if (this.e2ee && hasInsertableStreams()) {
|
|
125
|
+
// @ts-expect-error not part of the standard lib yet
|
|
126
|
+
config.encodedInsertableStreams = true;
|
|
127
|
+
}
|
|
128
|
+
const pc = new RTCPeerConnection(config);
|
|
118
129
|
pc.addEventListener('icecandidate', this.onIceCandidate);
|
|
119
130
|
pc.addEventListener('icecandidateerror', this.onIceCandidateError);
|
|
120
131
|
pc.addEventListener(
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -166,6 +166,14 @@ export class Publisher extends BasePeerConnection {
|
|
|
166
166
|
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
167
167
|
'maintain-framerate';
|
|
168
168
|
await transceiver.sender.setParameters(params);
|
|
169
|
+
if (this.e2ee) {
|
|
170
|
+
this.e2ee.encrypt(
|
|
171
|
+
transceiver.sender,
|
|
172
|
+
publishOption.codec?.name.toLowerCase(),
|
|
173
|
+
TrackType[publishOption.trackType],
|
|
174
|
+
);
|
|
175
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
176
|
+
}
|
|
169
177
|
|
|
170
178
|
await this.negotiate();
|
|
171
179
|
};
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -151,8 +151,13 @@ export class Subscriber extends BasePeerConnection {
|
|
|
151
151
|
trackLookupPrefix: trackId,
|
|
152
152
|
track: primaryStream,
|
|
153
153
|
trackType,
|
|
154
|
+
receiver: this.e2ee ? e.receiver : undefined,
|
|
154
155
|
});
|
|
155
156
|
return;
|
|
157
|
+
} else if (this.e2ee) {
|
|
158
|
+
const { userId } = participantToUpdate;
|
|
159
|
+
this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]);
|
|
160
|
+
this.logger.debug('E2EE decryptor attached to receiver', userId);
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
const streamKindProp = trackTypeToParticipantStreamKey(trackType);
|