@stream-io/video-client 1.55.1 → 1.56.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 +18 -0
- package/dist/index.browser.es.js +192 -103
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +192 -103
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +192 -103
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +4 -0
- package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/Publisher.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +9 -2
- package/dist/src/store/CallState.d.ts +4 -0
- package/dist/src/store/rxUtils.d.ts +5 -0
- package/package.json +2 -2
- package/src/Call.ts +8 -0
- package/src/helpers/BlockedAudioTracker.ts +71 -13
- package/src/helpers/DynascaleManager.ts +14 -3
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
- package/src/reporting/ClientEventReporter.ts +37 -31
- package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
- package/src/rtc/BasePeerConnection.ts +1 -0
- package/src/rtc/Publisher.ts +78 -58
- package/src/rtc/__tests__/Publisher.test.ts +297 -0
- package/src/rtc/types.ts +13 -11
- package/src/store/CallState.ts +12 -17
- package/src/store/rxUtils.ts +15 -0
package/dist/src/Call.d.ts
CHANGED
|
@@ -482,6 +482,10 @@ export declare class Call {
|
|
|
482
482
|
* @param criteria the list of criteria to sort the participants by.
|
|
483
483
|
*/
|
|
484
484
|
setSortParticipantsBy: CallState['setSortParticipantsBy'];
|
|
485
|
+
/**
|
|
486
|
+
* Returns the comparator currently used to sort the participants.
|
|
487
|
+
*/
|
|
488
|
+
getSortParticipantsBy: CallState['getSortParticipantsBy'];
|
|
485
489
|
/**
|
|
486
490
|
* Sends a reaction to the other call participants.
|
|
487
491
|
*
|
|
@@ -12,11 +12,26 @@ export declare class BlockedAudioTracker {
|
|
|
12
12
|
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
13
13
|
*/
|
|
14
14
|
autoplayBlocked$: import("rxjs").Observable<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* The list of participant `sessionId`s whose audio element is currently
|
|
17
|
+
* blocked by the browser's autoplay policy. Only some participants may be
|
|
18
|
+
* blocked (e.g. one joined while a gesture was active and a later one was
|
|
19
|
+
* not), so use this to render a per-participant affordance rather than a
|
|
20
|
+
* call-wide one. Call {@link resumeAudio} within a user gesture to unblock
|
|
21
|
+
* all of them.
|
|
22
|
+
*
|
|
23
|
+
* Session ids are registered together with the audio element.
|
|
24
|
+
*/
|
|
25
|
+
blockedSessionIds$: import("rxjs").Observable<string[]>;
|
|
15
26
|
constructor(tracer: Tracer);
|
|
16
27
|
/**
|
|
17
28
|
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
18
29
|
*/
|
|
19
|
-
markBlocked: (audioElement: HTMLAudioElement, blocked: boolean) => void;
|
|
30
|
+
markBlocked: (audioElement: HTMLAudioElement, blocked: boolean, sessionId?: string) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Clears all tracked elements. The tracker stays usable afterwards.
|
|
33
|
+
*/
|
|
34
|
+
reset: () => void;
|
|
20
35
|
/**
|
|
21
36
|
* Returns whether the given audio element is currently flagged as blocked
|
|
22
37
|
* by the browser's autoplay policy.
|
|
@@ -4,6 +4,7 @@ import type { PeerConnectionStateChangeEvent } from '../rtc';
|
|
|
4
4
|
export type ClientEventPeerConnection = 'publish' | 'subscribe';
|
|
5
5
|
export type ClientEventStage = 'JoinInitiated' | 'CoordinatorWS' | 'MediaDevicePermission' | 'CoordinatorJoin' | 'WSJoin' | 'PeerConnectionConnect' | 'FirstVideoFrame' | 'FirstAudioFrame';
|
|
6
6
|
export type MediaPermissionState = 'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
|
|
7
|
+
export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
|
|
7
8
|
export type JoinReason = 'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
|
|
8
9
|
export type ClientEventStandardCode = 'CLIENT_ABORTED' | 'BACKEND_LEAVE' | 'REQUEST_TIMEOUT' | 'NETWORK_ERROR' | 'SFU_ERROR' | 'SFU_GO_AWAY' | 'ICE_CONNECTIVITY_FAILED' | 'DTLS_CONNECTIVITY_FAILED';
|
|
9
10
|
export type CallReportContext = {
|
package/dist/src/rtc/types.d.ts
CHANGED
|
@@ -43,16 +43,23 @@ export type OnIceConnected = (peerType: PeerType) => void;
|
|
|
43
43
|
* Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
|
|
44
44
|
* consumers (e.g. `ClientEventReporter`). Fired on every transition of
|
|
45
45
|
* either `iceConnectionState` or `peerConnectionState`.
|
|
46
|
+
*
|
|
47
|
+
* `iceConnectionState` is the live `RTCPeerConnection.iceConnectionState` read
|
|
48
|
+
* at the moment the event fires, regardless of which transition triggered it.
|
|
49
|
+
* Consumers must use it (rather than assuming a value from `stateType`) because
|
|
50
|
+
* the browser does not guarantee the relative ordering of the `ice` and
|
|
51
|
+
* `peerConnection` `failed` transitions.
|
|
46
52
|
*/
|
|
47
53
|
export type PeerConnectionStateChangeEvent = {
|
|
48
54
|
peerType: PeerType;
|
|
55
|
+
iceConnectionState: RTCIceConnectionState;
|
|
56
|
+
} & ({
|
|
49
57
|
stateType: 'ice';
|
|
50
58
|
state: RTCIceConnectionState;
|
|
51
59
|
} | {
|
|
52
|
-
peerType: PeerType;
|
|
53
60
|
stateType: 'peerConnection';
|
|
54
61
|
state: RTCPeerConnectionState;
|
|
55
|
-
};
|
|
62
|
+
});
|
|
56
63
|
export type OnPeerConnectionStateChange = (event: PeerConnectionStateChangeEvent) => void;
|
|
57
64
|
/**
|
|
58
65
|
* Fired when a remote track starts receiving media (`unmute`). Used by
|
|
@@ -227,6 +227,10 @@ export declare class CallState {
|
|
|
227
227
|
* @param comparator the comparator to use to sort the participants.
|
|
228
228
|
*/
|
|
229
229
|
setSortParticipantsBy: (comparator: Comparator<StreamVideoParticipant>) => void;
|
|
230
|
+
/**
|
|
231
|
+
* Returns the comparator currently used to sort the participants.
|
|
232
|
+
*/
|
|
233
|
+
getSortParticipantsBy: () => Comparator<StreamVideoParticipant>;
|
|
230
234
|
/**
|
|
231
235
|
* Gets the current value of an observable, or undefined if the observable has
|
|
232
236
|
* not emitted a value yet.
|
|
@@ -5,6 +5,11 @@ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
|
|
|
5
5
|
* A value or a function which takes the current value and returns a new value.
|
|
6
6
|
*/
|
|
7
7
|
export type Patch<T> = T | FunctionPatch<T>;
|
|
8
|
+
/**
|
|
9
|
+
* Performs shallow comparison of two arrays.
|
|
10
|
+
* Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
|
|
11
|
+
*/
|
|
12
|
+
export declare const isShallowArrayEqual: <T>(a: Array<T>, b: Array<T>) => boolean;
|
|
8
13
|
/**
|
|
9
14
|
* Gets the current value of an observable, or undefined if the observable has
|
|
10
15
|
* not emitted a value yet.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stream-io/video-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.0",
|
|
4
4
|
"main": "dist/index.cjs.js",
|
|
5
5
|
"module": "dist/index.es.js",
|
|
6
6
|
"browser": "dist/index.browser.es.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@openapitools/openapi-generator-cli": "^2.34.0",
|
|
47
47
|
"@rollup/plugin-replace": "^6.0.3",
|
|
48
48
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
49
|
-
"@stream-io/audio-filters-web": "^0.
|
|
49
|
+
"@stream-io/audio-filters-web": "^0.10.2",
|
|
50
50
|
"@stream-io/node-sdk": "^0.7.59",
|
|
51
51
|
"@stream-io/typescript-config": "^0.0.0",
|
|
52
52
|
"@total-typescript/shoehorn": "^0.1.2",
|
package/src/Call.ts
CHANGED
|
@@ -404,6 +404,7 @@ export class Call {
|
|
|
404
404
|
this.tracer,
|
|
405
405
|
);
|
|
406
406
|
this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
|
|
407
|
+
this.leaveCallHooks.add(this.blockedAudioTracker.reset);
|
|
407
408
|
|
|
408
409
|
if (typeof document !== 'undefined') {
|
|
409
410
|
this.audioBindingsWatchdog = new AudioBindingsWatchdog(
|
|
@@ -2439,6 +2440,13 @@ export class Call {
|
|
|
2439
2440
|
return this.state.setSortParticipantsBy(criteria);
|
|
2440
2441
|
};
|
|
2441
2442
|
|
|
2443
|
+
/**
|
|
2444
|
+
* Returns the comparator currently used to sort the participants.
|
|
2445
|
+
*/
|
|
2446
|
+
getSortParticipantsBy: CallState['getSortParticipantsBy'] = () => {
|
|
2447
|
+
return this.state.getSortParticipantsBy();
|
|
2448
|
+
};
|
|
2449
|
+
|
|
2442
2450
|
/**
|
|
2443
2451
|
* Sends a reaction to the other call participants.
|
|
2444
2452
|
*
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
|
|
1
|
+
import { BehaviorSubject, distinctUntilChanged, map, shareReplay } from 'rxjs';
|
|
2
2
|
import { videoLoggerSystem } from '../logger';
|
|
3
3
|
import { Tracer } from '../stats';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isShallowArrayEqual,
|
|
6
|
+
setCurrentValue,
|
|
7
|
+
setCurrentValueAsync,
|
|
8
|
+
} from '../store/rxUtils';
|
|
5
9
|
import { timeboxed } from '../coordinator/connection/utils';
|
|
6
10
|
|
|
11
|
+
type BlockedAudioElement = {
|
|
12
|
+
element: HTMLAudioElement;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
7
16
|
/**
|
|
8
17
|
* Tracks audio elements that the browser's autoplay policy has blocked.
|
|
9
18
|
*/
|
|
@@ -11,8 +20,8 @@ export class BlockedAudioTracker {
|
|
|
11
20
|
private logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
|
|
12
21
|
private tracer: Tracer;
|
|
13
22
|
|
|
14
|
-
private blockedElementsSubject = new BehaviorSubject(
|
|
15
|
-
|
|
23
|
+
private blockedElementsSubject = new BehaviorSubject<BlockedAudioElement[]>(
|
|
24
|
+
[],
|
|
16
25
|
);
|
|
17
26
|
|
|
18
27
|
/**
|
|
@@ -21,10 +30,32 @@ export class BlockedAudioTracker {
|
|
|
21
30
|
* Use {@link resumeAudio} within a user gesture to unblock.
|
|
22
31
|
*/
|
|
23
32
|
autoplayBlocked$ = this.blockedElementsSubject.pipe(
|
|
24
|
-
map((elements) => elements.
|
|
33
|
+
map((elements) => elements.length > 0),
|
|
25
34
|
distinctUntilChanged(),
|
|
26
35
|
);
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The list of participant `sessionId`s whose audio element is currently
|
|
39
|
+
* blocked by the browser's autoplay policy. Only some participants may be
|
|
40
|
+
* blocked (e.g. one joined while a gesture was active and a later one was
|
|
41
|
+
* not), so use this to render a per-participant affordance rather than a
|
|
42
|
+
* call-wide one. Call {@link resumeAudio} within a user gesture to unblock
|
|
43
|
+
* all of them.
|
|
44
|
+
*
|
|
45
|
+
* Session ids are registered together with the audio element.
|
|
46
|
+
*/
|
|
47
|
+
blockedSessionIds$ = this.blockedElementsSubject.pipe(
|
|
48
|
+
map((elements) => {
|
|
49
|
+
const ids: string[] = [];
|
|
50
|
+
elements.forEach(({ sessionId }) => {
|
|
51
|
+
if (sessionId && !ids.includes(sessionId)) ids.push(sessionId);
|
|
52
|
+
});
|
|
53
|
+
return ids;
|
|
54
|
+
}),
|
|
55
|
+
distinctUntilChanged(isShallowArrayEqual),
|
|
56
|
+
shareReplay({ bufferSize: 1, refCount: true }),
|
|
57
|
+
);
|
|
58
|
+
|
|
28
59
|
constructor(tracer: Tracer) {
|
|
29
60
|
this.tracer = tracer;
|
|
30
61
|
}
|
|
@@ -32,20 +63,46 @@ export class BlockedAudioTracker {
|
|
|
32
63
|
/**
|
|
33
64
|
* Registers an audio element as blocked by the browser's autoplay policy.
|
|
34
65
|
*/
|
|
35
|
-
markBlocked = (
|
|
66
|
+
markBlocked = (
|
|
67
|
+
audioElement: HTMLAudioElement,
|
|
68
|
+
blocked: boolean,
|
|
69
|
+
sessionId?: string,
|
|
70
|
+
) => {
|
|
36
71
|
setCurrentValue(this.blockedElementsSubject, (elements) => {
|
|
37
|
-
if (blocked)
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
if (!blocked) {
|
|
73
|
+
return elements.filter(({ element }) => element !== audioElement);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const existing = elements.find(({ element }) => element === audioElement);
|
|
77
|
+
if (existing) {
|
|
78
|
+
return elements.map((entry) =>
|
|
79
|
+
entry.element === audioElement
|
|
80
|
+
? { element: audioElement, sessionId: sessionId ?? entry.sessionId }
|
|
81
|
+
: entry,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return [...elements, { element: audioElement, sessionId }];
|
|
40
86
|
});
|
|
41
87
|
};
|
|
42
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Clears all tracked elements. The tracker stays usable afterwards.
|
|
91
|
+
*/
|
|
92
|
+
reset = () => {
|
|
93
|
+
if (this.blockedElementsSubject.getValue().length > 0) {
|
|
94
|
+
this.blockedElementsSubject.next([]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
43
98
|
/**
|
|
44
99
|
* Returns whether the given audio element is currently flagged as blocked
|
|
45
100
|
* by the browser's autoplay policy.
|
|
46
101
|
*/
|
|
47
102
|
isBlocked = (audioElement: HTMLAudioElement): boolean => {
|
|
48
|
-
return this.blockedElementsSubject
|
|
103
|
+
return this.blockedElementsSubject
|
|
104
|
+
.getValue()
|
|
105
|
+
.some(({ element }) => element === audioElement);
|
|
49
106
|
};
|
|
50
107
|
|
|
51
108
|
/**
|
|
@@ -57,17 +114,18 @@ export class BlockedAudioTracker {
|
|
|
57
114
|
await setCurrentValueAsync(
|
|
58
115
|
this.blockedElementsSubject,
|
|
59
116
|
async (elements) => {
|
|
117
|
+
let next = elements;
|
|
60
118
|
await Promise.all(
|
|
61
|
-
|
|
119
|
+
elements.map(async ({ element }) => {
|
|
62
120
|
try {
|
|
63
121
|
if (element.srcObject) await timeboxed([element.play()], 2000);
|
|
64
|
-
|
|
122
|
+
next = next.filter((entry) => entry.element !== element);
|
|
65
123
|
} catch (err) {
|
|
66
124
|
this.logger.warn(`Can't resume audio for element`, element, err);
|
|
67
125
|
}
|
|
68
126
|
}),
|
|
69
127
|
);
|
|
70
|
-
return
|
|
128
|
+
return next;
|
|
71
129
|
},
|
|
72
130
|
);
|
|
73
131
|
};
|
|
@@ -328,6 +328,12 @@ export class DynascaleManager {
|
|
|
328
328
|
let gainNode: GainNode | undefined = undefined;
|
|
329
329
|
let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
|
|
330
330
|
|
|
331
|
+
const clearBlockedAudio = () => {
|
|
332
|
+
if (!this.blockedAudioTracker.isBlocked(audioElement)) return;
|
|
333
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
334
|
+
};
|
|
335
|
+
audioElement.addEventListener('playing', clearBlockedAudio);
|
|
336
|
+
|
|
331
337
|
const isAudioTrack = trackType === 'audioTrack';
|
|
332
338
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
333
339
|
const updateMediaStreamSubscription = participant$
|
|
@@ -341,7 +347,7 @@ export class DynascaleManager {
|
|
|
341
347
|
audioWatchdog?.dispose();
|
|
342
348
|
audioWatchdog = undefined;
|
|
343
349
|
if (!source) {
|
|
344
|
-
|
|
350
|
+
clearBlockedAudio();
|
|
345
351
|
return;
|
|
346
352
|
}
|
|
347
353
|
|
|
@@ -369,7 +375,11 @@ export class DynascaleManager {
|
|
|
369
375
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
370
376
|
if (e.name === 'NotAllowedError') {
|
|
371
377
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
372
|
-
this.blockedAudioTracker.markBlocked(
|
|
378
|
+
this.blockedAudioTracker.markBlocked(
|
|
379
|
+
audioElement,
|
|
380
|
+
true,
|
|
381
|
+
sessionId,
|
|
382
|
+
);
|
|
373
383
|
}
|
|
374
384
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
375
385
|
});
|
|
@@ -405,7 +415,8 @@ export class DynascaleManager {
|
|
|
405
415
|
audioElement.autoplay = true;
|
|
406
416
|
|
|
407
417
|
return () => {
|
|
408
|
-
|
|
418
|
+
audioElement.removeEventListener('playing', clearBlockedAudio);
|
|
419
|
+
clearBlockedAudio();
|
|
409
420
|
sinkIdSubscription?.unsubscribe();
|
|
410
421
|
volumeSubscription.unsubscribe();
|
|
411
422
|
updateMediaStreamSubscription.unsubscribe();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @vitest-environment happy-dom
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { BlockedAudioTracker } from '../BlockedAudioTracker';
|
|
7
7
|
import { getCurrentValue } from '../../store/rxUtils';
|
|
8
8
|
import type { Tracer } from '../../stats';
|
|
@@ -30,6 +30,10 @@ describe('BlockedAudioTracker', () => {
|
|
|
30
30
|
tracker = new BlockedAudioTracker(tracer);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
tracker.reset();
|
|
35
|
+
});
|
|
36
|
+
|
|
33
37
|
it('emits autoplayBlocked$ true after markBlocked(el, true)', () => {
|
|
34
38
|
expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
|
|
35
39
|
tracker.markBlocked(createAudioElement(), true);
|
|
@@ -58,14 +62,58 @@ describe('BlockedAudioTracker', () => {
|
|
|
58
62
|
expect(getCurrentValue(tracker.autoplayBlocked$)).toBe(false);
|
|
59
63
|
});
|
|
60
64
|
|
|
65
|
+
it('emits blocked participant session ids', () => {
|
|
66
|
+
const el1 = createAudioElement();
|
|
67
|
+
const el2 = createAudioElement();
|
|
68
|
+
|
|
69
|
+
tracker.markBlocked(el1, true, 'session-id-1');
|
|
70
|
+
tracker.markBlocked(el2, true, 'session-id-2');
|
|
71
|
+
|
|
72
|
+
expect(getCurrentValue(tracker.blockedSessionIds$)).toEqual([
|
|
73
|
+
'session-id-1',
|
|
74
|
+
'session-id-2',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
tracker.markBlocked(el1, false);
|
|
78
|
+
|
|
79
|
+
expect(getCurrentValue(tracker.blockedSessionIds$)).toEqual([
|
|
80
|
+
'session-id-2',
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it(`doesn't emit when blocked participant session ids don't change`, () => {
|
|
85
|
+
const el1 = createAudioElement();
|
|
86
|
+
const el2 = createAudioElement();
|
|
87
|
+
tracker.markBlocked(el1, true, 'session-id-1');
|
|
88
|
+
|
|
89
|
+
const subscriber = vi.fn();
|
|
90
|
+
const subscription = tracker.blockedSessionIds$.subscribe(subscriber);
|
|
91
|
+
|
|
92
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
93
|
+
|
|
94
|
+
tracker.markBlocked(el1, true, 'session-id-1');
|
|
95
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
96
|
+
|
|
97
|
+
tracker.markBlocked(el2, true, 'session-id-1');
|
|
98
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
99
|
+
|
|
100
|
+
tracker.markBlocked(el1, false);
|
|
101
|
+
expect(subscriber).toHaveBeenCalledTimes(1);
|
|
102
|
+
|
|
103
|
+
tracker.markBlocked(el2, false);
|
|
104
|
+
expect(subscriber).toHaveBeenCalledTimes(2);
|
|
105
|
+
|
|
106
|
+
subscription.unsubscribe();
|
|
107
|
+
});
|
|
108
|
+
|
|
61
109
|
it('resumeAudio plays each element with srcObject and clears successful ones', async () => {
|
|
62
110
|
const el1 = createAudioElement();
|
|
63
111
|
const el2 = createAudioElement();
|
|
64
112
|
const play1 = vi.spyOn(el1, 'play').mockResolvedValue();
|
|
65
113
|
const play2 = vi.spyOn(el2, 'play').mockResolvedValue();
|
|
66
114
|
|
|
67
|
-
tracker.markBlocked(el1, true);
|
|
68
|
-
tracker.markBlocked(el2, true);
|
|
115
|
+
tracker.markBlocked(el1, true, 'session-id-1');
|
|
116
|
+
tracker.markBlocked(el2, true, 'session-id-2');
|
|
69
117
|
|
|
70
118
|
await tracker.resumeAudio();
|
|
71
119
|
|
|
@@ -82,8 +130,8 @@ describe('BlockedAudioTracker', () => {
|
|
|
82
130
|
new DOMException('', 'NotAllowedError'),
|
|
83
131
|
);
|
|
84
132
|
|
|
85
|
-
tracker.markBlocked(ok, true);
|
|
86
|
-
tracker.markBlocked(stillBlocked, true);
|
|
133
|
+
tracker.markBlocked(ok, true, 'session-id-1');
|
|
134
|
+
tracker.markBlocked(stillBlocked, true, 'session-id-2');
|
|
87
135
|
|
|
88
136
|
await tracker.resumeAudio();
|
|
89
137
|
|
|
@@ -100,7 +148,7 @@ describe('BlockedAudioTracker', () => {
|
|
|
100
148
|
const detached = createAudioElement(false);
|
|
101
149
|
const play = vi.spyOn(detached, 'play').mockResolvedValue();
|
|
102
150
|
|
|
103
|
-
tracker.markBlocked(detached, true);
|
|
151
|
+
tracker.markBlocked(detached, true, 'session-id');
|
|
104
152
|
await tracker.resumeAudio();
|
|
105
153
|
|
|
106
154
|
expect(play).not.toHaveBeenCalled();
|
|
@@ -143,6 +143,51 @@ describe('DynascaleManager', () => {
|
|
|
143
143
|
cleanup?.();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it('audio: tracks the blocked session and clears it once the element plays', async () => {
|
|
147
|
+
vi.useFakeTimers();
|
|
148
|
+
let cleanup: ReturnType<typeof dynascaleManager.bindAudioElement>;
|
|
149
|
+
try {
|
|
150
|
+
const audioElement = document.createElement('audio');
|
|
151
|
+
Object.defineProperty(audioElement, 'srcObject', { writable: true });
|
|
152
|
+
vi.spyOn(audioElement, 'play').mockRejectedValue(
|
|
153
|
+
new DOMException('', 'NotAllowedError'),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// @ts-expect-error incomplete data
|
|
157
|
+
call.state.updateOrAddParticipant('session-id', {
|
|
158
|
+
userId: 'user-id',
|
|
159
|
+
sessionId: 'session-id',
|
|
160
|
+
publishedTracks: [],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
cleanup = dynascaleManager.bindAudioElement(
|
|
164
|
+
audioElement,
|
|
165
|
+
'session-id',
|
|
166
|
+
'audioTrack',
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
call.state.updateParticipant('session-id', {
|
|
170
|
+
audioStream: new MediaStream(),
|
|
171
|
+
});
|
|
172
|
+
await vi.advanceTimersByTimeAsync(0); // flush the rejected play()
|
|
173
|
+
|
|
174
|
+
expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(true);
|
|
175
|
+
expect(
|
|
176
|
+
getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
|
|
177
|
+
).toEqual(['session-id']);
|
|
178
|
+
|
|
179
|
+
audioElement.dispatchEvent(new Event('playing'));
|
|
180
|
+
|
|
181
|
+
expect(call.blockedAudioTracker.isBlocked(audioElement)).toBe(false);
|
|
182
|
+
expect(
|
|
183
|
+
getCurrentValue(call.blockedAudioTracker.blockedSessionIds$),
|
|
184
|
+
).toEqual([]);
|
|
185
|
+
} finally {
|
|
186
|
+
cleanup?.();
|
|
187
|
+
vi.useRealTimers();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
146
191
|
it('audio: Safari should use AudioContext for audio playback', () => {
|
|
147
192
|
globalThis._isSafari = true;
|
|
148
193
|
dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
|
|
@@ -38,6 +38,8 @@ export type MediaPermissionState =
|
|
|
38
38
|
| 'GRANTED'
|
|
39
39
|
| 'NOT_INITIATED';
|
|
40
40
|
|
|
41
|
+
export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
|
|
42
|
+
|
|
41
43
|
export type JoinReason =
|
|
42
44
|
| 'first-attempt'
|
|
43
45
|
| 'network-available'
|
|
@@ -86,6 +88,7 @@ type PeerConnectionPairState = StagePairState & {
|
|
|
86
88
|
sfuId: string;
|
|
87
89
|
userSessionId: string;
|
|
88
90
|
wasPreviouslyConnected: boolean;
|
|
91
|
+
lastIceState?: RTCIceConnectionState;
|
|
89
92
|
};
|
|
90
93
|
|
|
91
94
|
const pcKey = (cid: string, role: ClientEventPeerConnection): string =>
|
|
@@ -356,20 +359,8 @@ export class ClientEventReporter {
|
|
|
356
359
|
this.failCoordinator(cid);
|
|
357
360
|
this.failWs(cid);
|
|
358
361
|
|
|
359
|
-
this.emitPeerConnectionFailure(
|
|
360
|
-
|
|
361
|
-
'publish',
|
|
362
|
-
code,
|
|
363
|
-
reason,
|
|
364
|
-
'NOT_CONNECTED',
|
|
365
|
-
);
|
|
366
|
-
this.emitPeerConnectionFailure(
|
|
367
|
-
cid,
|
|
368
|
-
'subscribe',
|
|
369
|
-
code,
|
|
370
|
-
reason,
|
|
371
|
-
'NOT_CONNECTED',
|
|
372
|
-
);
|
|
362
|
+
this.emitPeerConnectionFailure(cid, 'publish', code, reason);
|
|
363
|
+
this.emitPeerConnectionFailure(cid, 'subscribe', code, reason);
|
|
373
364
|
} catch (err) {
|
|
374
365
|
this.logger.warn('Failed to report abort', err);
|
|
375
366
|
}
|
|
@@ -384,7 +375,6 @@ export class ClientEventReporter {
|
|
|
384
375
|
role,
|
|
385
376
|
'CLIENT_ABORTED',
|
|
386
377
|
'superseded by a new join attempt',
|
|
387
|
-
'NOT_CONNECTED',
|
|
388
378
|
);
|
|
389
379
|
}
|
|
390
380
|
};
|
|
@@ -573,34 +563,35 @@ export class ClientEventReporter {
|
|
|
573
563
|
const role: ClientEventPeerConnection =
|
|
574
564
|
event.peerType === PeerType.SUBSCRIBER ? 'subscribe' : 'publish';
|
|
575
565
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
566
|
+
const pair = this.peerConnectionPairs.get(pcKey(cid, role));
|
|
567
|
+
if (pair) pair.lastIceState = event.iceConnectionState;
|
|
568
|
+
|
|
569
|
+
if (event.stateType === 'ice') {
|
|
570
|
+
if (event.state === 'failed') {
|
|
571
|
+
this.emitPeerConnectionFailure(
|
|
572
|
+
cid,
|
|
573
|
+
role,
|
|
574
|
+
'ICE_CONNECTIVITY_FAILED',
|
|
575
|
+
'ICE connectivity checks failed',
|
|
576
|
+
);
|
|
577
|
+
}
|
|
584
578
|
return;
|
|
585
579
|
}
|
|
586
580
|
|
|
587
|
-
if (event.
|
|
581
|
+
if (event.state === 'failed') {
|
|
588
582
|
this.emitPeerConnectionFailure(
|
|
589
583
|
cid,
|
|
590
584
|
role,
|
|
591
585
|
'DTLS_CONNECTIVITY_FAILED',
|
|
592
586
|
'DTLS connectivity checks failed',
|
|
593
|
-
'CONNECTED',
|
|
594
587
|
);
|
|
595
588
|
return;
|
|
596
589
|
}
|
|
597
590
|
|
|
598
|
-
if (event.stateType !== 'peerConnection') return;
|
|
599
|
-
|
|
600
591
|
switch (event.state) {
|
|
601
592
|
case 'connecting':
|
|
602
|
-
if (
|
|
603
|
-
this.openPeerConnectionPair(cid, role);
|
|
593
|
+
if (pair) return;
|
|
594
|
+
this.openPeerConnectionPair(cid, role, event.iceConnectionState);
|
|
604
595
|
break;
|
|
605
596
|
case 'connected':
|
|
606
597
|
this.emitPeerConnectionSuccess(cid, role);
|
|
@@ -614,6 +605,7 @@ export class ClientEventReporter {
|
|
|
614
605
|
private openPeerConnectionPair = (
|
|
615
606
|
cid: string,
|
|
616
607
|
role: ClientEventPeerConnection,
|
|
608
|
+
iceConnectionState: RTCIceConnectionState,
|
|
617
609
|
) => {
|
|
618
610
|
const key = pcKey(cid, role);
|
|
619
611
|
const pair: PeerConnectionPairState = {
|
|
@@ -624,6 +616,7 @@ export class ClientEventReporter {
|
|
|
624
616
|
sfuId: this.getSfuId(cid),
|
|
625
617
|
userSessionId: this.getUserSessionId(cid),
|
|
626
618
|
wasPreviouslyConnected: this.pcEverConnected.get(key) === true,
|
|
619
|
+
lastIceState: iceConnectionState,
|
|
627
620
|
};
|
|
628
621
|
this.peerConnectionPairs.set(key, pair);
|
|
629
622
|
|
|
@@ -670,7 +663,6 @@ export class ClientEventReporter {
|
|
|
670
663
|
role: ClientEventPeerConnection,
|
|
671
664
|
code: ClientEventStandardCode,
|
|
672
665
|
reason: string,
|
|
673
|
-
iceState: 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED',
|
|
674
666
|
) => {
|
|
675
667
|
const key = pcKey(cid, role);
|
|
676
668
|
const pair = this.peerConnectionPairs.get(key);
|
|
@@ -689,7 +681,7 @@ export class ClientEventReporter {
|
|
|
689
681
|
outcome: 'failure',
|
|
690
682
|
retry_count_attempt: 0,
|
|
691
683
|
elapsed_time: Date.now() - pair.startedAt,
|
|
692
|
-
ice_state:
|
|
684
|
+
ice_state: toReportedIceState(pair.lastIceState),
|
|
693
685
|
retry_failure_reason: reason,
|
|
694
686
|
retry_failure_code: code,
|
|
695
687
|
});
|
|
@@ -782,6 +774,20 @@ export class ClientEventReporter {
|
|
|
782
774
|
};
|
|
783
775
|
}
|
|
784
776
|
|
|
777
|
+
const toReportedIceState = (
|
|
778
|
+
state: RTCIceConnectionState | undefined,
|
|
779
|
+
): ReportedIceState => {
|
|
780
|
+
switch (state) {
|
|
781
|
+
case 'connected':
|
|
782
|
+
case 'completed':
|
|
783
|
+
return 'CONNECTED';
|
|
784
|
+
case 'failed':
|
|
785
|
+
return 'FAILED';
|
|
786
|
+
default:
|
|
787
|
+
return 'NOT_CONNECTED';
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
|
|
785
791
|
const readPermissionStatus = (
|
|
786
792
|
permission: BrowserPermission,
|
|
787
793
|
): MediaPermissionState => {
|