@stream-io/video-client 1.55.2 → 1.57.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 +22 -0
- package/dist/index.browser.es.js +216 -119
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +216 -119
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +216 -119
- package/dist/index.es.js.map +1 -1
- package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- 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/rxUtils.d.ts +5 -0
- package/package.json +11 -11
- package/src/Call.ts +1 -0
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -2
- package/src/devices/devicePersistence.ts +1 -2
- package/src/helpers/BlockedAudioTracker.ts +71 -13
- package/src/helpers/DynascaleManager.ts +14 -3
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/reporting/ClientEventReporter.ts +39 -39
- 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 +14 -13
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +6 -19
- package/src/store/rxUtils.ts +15 -0
- package/src/types.ts +1 -4
|
@@ -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.
|
|
@@ -18,12 +18,16 @@ export declare class MediaPlaybackWatchdog {
|
|
|
18
18
|
private tracer;
|
|
19
19
|
private controller;
|
|
20
20
|
private pendingTimer;
|
|
21
|
+
private settleTimer;
|
|
21
22
|
private attempt;
|
|
22
23
|
private disposed;
|
|
24
|
+
private stopped;
|
|
23
25
|
constructor(opts: MediaPlaybackWatchdogOptions);
|
|
24
26
|
private attach;
|
|
25
27
|
dispose: () => void;
|
|
28
|
+
private clearTimers;
|
|
26
29
|
private onPlaying;
|
|
30
|
+
private onSettled;
|
|
27
31
|
private onPauseOrSuspend;
|
|
28
32
|
private scheduleRecovery;
|
|
29
33
|
private computeSkipReason;
|
|
@@ -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
|
|
@@ -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.57.0",
|
|
4
4
|
"main": "dist/index.cjs.js",
|
|
5
5
|
"module": "dist/index.es.js",
|
|
6
6
|
"browser": "dist/index.browser.es.js",
|
|
@@ -36,31 +36,31 @@
|
|
|
36
36
|
"@protobuf-ts/twirp-transport": "^2.11.1",
|
|
37
37
|
"@stream-io/logger": "^2.0.0",
|
|
38
38
|
"@stream-io/worker-timer": "^1.2.5",
|
|
39
|
-
"axios": "^1.
|
|
39
|
+
"axios": "^1.19.0",
|
|
40
40
|
"rxjs": "~7.8.2",
|
|
41
41
|
"sdp-transform": "^2.15.0",
|
|
42
42
|
"ua-parser-js": "^1.0.41",
|
|
43
43
|
"webrtc-adapter": "^8.2.4"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@openapitools/openapi-generator-cli": "^2.
|
|
46
|
+
"@openapitools/openapi-generator-cli": "^2.40.1",
|
|
47
47
|
"@rollup/plugin-replace": "^6.0.3",
|
|
48
48
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
49
|
-
"@stream-io/audio-filters-web": "^0.10.
|
|
50
|
-
"@stream-io/node-sdk": "^0.7.
|
|
49
|
+
"@stream-io/audio-filters-web": "^0.10.4",
|
|
50
|
+
"@stream-io/node-sdk": "^0.7.63",
|
|
51
51
|
"@stream-io/typescript-config": "^0.0.0",
|
|
52
52
|
"@total-typescript/shoehorn": "^0.1.2",
|
|
53
|
-
"@types/node": "^25.9.
|
|
53
|
+
"@types/node": "^25.9.5",
|
|
54
54
|
"@types/sdp-transform": "^2.15.0",
|
|
55
55
|
"@types/ua-parser-js": "^0.7.39",
|
|
56
|
-
"@vitest/coverage-v8": "^4.1.
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
57
57
|
"dotenv": "^16.6.1",
|
|
58
|
-
"happy-dom": "^20.
|
|
59
|
-
"prettier": "^3.
|
|
58
|
+
"happy-dom": "^20.11.1",
|
|
59
|
+
"prettier": "^3.9.6",
|
|
60
60
|
"rimraf": "^6.1.3",
|
|
61
|
-
"rollup": "^4.
|
|
61
|
+
"rollup": "^4.62.4",
|
|
62
62
|
"typescript": "^6.0.3",
|
|
63
|
-
"vitest": "^4.1.
|
|
63
|
+
"vitest": "^4.1.10",
|
|
64
64
|
"vitest-mock-extended": "^4.0.0"
|
|
65
65
|
}
|
|
66
66
|
}
|
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(
|
|
@@ -16,8 +16,7 @@ import { settled, withoutConcurrency } from '../../helpers/concurrency';
|
|
|
16
16
|
import { ClientEventReporter } from '../../reporting';
|
|
17
17
|
|
|
18
18
|
let speechActivityCallback:
|
|
19
|
-
|
|
20
|
-
| null = null;
|
|
19
|
+
((state: { isSoundDetected: boolean }) => void) | null = null;
|
|
21
20
|
let unsubscribeMocks: ReturnType<typeof vi.fn>[] = [];
|
|
22
21
|
|
|
23
22
|
vi.mock('../../helpers/platforms.ts', () => {
|
|
@@ -24,8 +24,7 @@ export type LocalDevicePreference = {
|
|
|
24
24
|
|
|
25
25
|
export type LocalDevicePreferences = {
|
|
26
26
|
[type in DevicePreferenceKey]?:
|
|
27
|
-
| LocalDevicePreference
|
|
28
|
-
| LocalDevicePreference[];
|
|
27
|
+
LocalDevicePreference | LocalDevicePreference[];
|
|
29
28
|
};
|
|
30
29
|
|
|
31
30
|
export const defaultDeviceId = 'default';
|
|
@@ -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();
|
|
@@ -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
|
};
|
|
@@ -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
|