@stream-io/video-client 1.49.0 → 1.50.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 +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +89 -22
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -4,9 +4,8 @@ import {
|
|
|
4
4
|
VideoTrackType,
|
|
5
5
|
VisibilityState,
|
|
6
6
|
} from '../types';
|
|
7
|
-
import {
|
|
7
|
+
import { VideoDimension } from '../gen/video/sfu/models/models';
|
|
8
8
|
import {
|
|
9
|
-
BehaviorSubject,
|
|
10
9
|
combineLatest,
|
|
11
10
|
distinctUntilChanged,
|
|
12
11
|
distinctUntilKeyChanged,
|
|
@@ -14,163 +13,55 @@ import {
|
|
|
14
13
|
shareReplay,
|
|
15
14
|
takeWhile,
|
|
16
15
|
} from 'rxjs';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
16
|
+
import type { BlockedAudioTracker } from './BlockedAudioTracker';
|
|
17
|
+
import { MediaPlaybackWatchdog } from './MediaPlaybackWatchdog';
|
|
18
|
+
import type { TrackSubscriptionManager } from './TrackSubscriptionManager';
|
|
19
19
|
import { isFirefox, isSafari } from './browsers';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
hasScreenShare,
|
|
23
|
-
hasScreenShareAudio,
|
|
24
|
-
hasVideo,
|
|
25
|
-
} from './participantUtils';
|
|
26
|
-
import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
|
|
20
|
+
import { hasScreenShare, hasVideo } from './participantUtils';
|
|
27
21
|
import { CallState } from '../store';
|
|
28
|
-
import type { StreamSfuClient } from '../StreamSfuClient';
|
|
29
22
|
import { SpeakerManager } from '../devices';
|
|
30
|
-
import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
|
|
31
23
|
import { videoLoggerSystem } from '../logger';
|
|
32
24
|
import { Tracer } from '../stats';
|
|
33
|
-
|
|
34
|
-
const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
|
|
35
|
-
VideoTrackType,
|
|
36
|
-
VisibilityState
|
|
37
|
-
> = {
|
|
38
|
-
videoTrack: VisibilityState.UNKNOWN,
|
|
39
|
-
screenShareTrack: VisibilityState.UNKNOWN,
|
|
40
|
-
} as const;
|
|
41
|
-
|
|
42
|
-
type VideoTrackSubscriptionOverride =
|
|
43
|
-
| {
|
|
44
|
-
enabled: true;
|
|
45
|
-
dimension: VideoDimension;
|
|
46
|
-
}
|
|
47
|
-
| { enabled: false };
|
|
48
|
-
|
|
49
|
-
const globalOverrideKey = Symbol('globalOverrideKey');
|
|
50
|
-
|
|
51
|
-
interface VideoTrackSubscriptionOverrides {
|
|
52
|
-
[sessionId: string]: VideoTrackSubscriptionOverride | undefined;
|
|
53
|
-
[globalOverrideKey]?: VideoTrackSubscriptionOverride;
|
|
54
|
-
}
|
|
25
|
+
import { timeboxed } from '../coordinator/connection/utils';
|
|
55
26
|
|
|
56
27
|
/**
|
|
57
28
|
* A manager class that handles dynascale related tasks like:
|
|
58
29
|
*
|
|
59
30
|
* - binding video elements to session ids
|
|
60
31
|
* - binding audio elements to session ids
|
|
61
|
-
* - tracking element visibility
|
|
62
|
-
* - updating subscriptions based on viewport visibility
|
|
63
|
-
* - updating subscriptions based on video element dimensions
|
|
64
|
-
* - updating subscriptions based on published tracks
|
|
65
32
|
*/
|
|
66
33
|
export class DynascaleManager {
|
|
67
|
-
/**
|
|
68
|
-
* The viewport tracker instance.
|
|
69
|
-
*/
|
|
70
|
-
readonly viewportTracker = new ViewportTracker();
|
|
71
|
-
|
|
72
34
|
private logger = videoLoggerSystem.getLogger('DynascaleManager');
|
|
73
35
|
private callState: CallState;
|
|
74
36
|
private speaker: SpeakerManager;
|
|
75
|
-
private tracer: Tracer;
|
|
37
|
+
private readonly tracer: Tracer;
|
|
76
38
|
private useWebAudio = false;
|
|
77
39
|
private audioContext: AudioContext | undefined;
|
|
78
|
-
private sfuClient: StreamSfuClient | undefined;
|
|
79
|
-
private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
|
|
80
|
-
readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
|
|
81
40
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
* These can be retried by calling `resumeAudio()` from a user gesture.
|
|
85
|
-
*/
|
|
86
|
-
private blockedAudioElementsSubject = new BehaviorSubject<
|
|
87
|
-
Set<HTMLAudioElement>
|
|
88
|
-
>(new Set());
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Whether the browser's autoplay policy is blocking audio playback.
|
|
92
|
-
* Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
|
|
93
|
-
* Use `resumeAudio()` within a user gesture to unblock.
|
|
94
|
-
*/
|
|
95
|
-
autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(
|
|
96
|
-
map((elements) => elements.size > 0),
|
|
97
|
-
distinctUntilChanged(),
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
private addBlockedAudioElement = (audioElement: HTMLAudioElement) => {
|
|
101
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
102
|
-
const next = new Set(elements);
|
|
103
|
-
next.add(audioElement);
|
|
104
|
-
return next;
|
|
105
|
-
});
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
private removeBlockedAudioElement = (audioElement: HTMLAudioElement) => {
|
|
109
|
-
setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
|
|
110
|
-
const nextElements = new Set(elements);
|
|
111
|
-
nextElements.delete(audioElement);
|
|
112
|
-
return nextElements;
|
|
113
|
-
});
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
private videoTrackSubscriptionOverridesSubject =
|
|
117
|
-
new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
|
|
118
|
-
|
|
119
|
-
videoTrackSubscriptionOverrides$ =
|
|
120
|
-
this.videoTrackSubscriptionOverridesSubject.asObservable();
|
|
121
|
-
|
|
122
|
-
incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(
|
|
123
|
-
map((overrides) => {
|
|
124
|
-
const { [globalOverrideKey]: globalSettings, ...participants } =
|
|
125
|
-
overrides;
|
|
126
|
-
return {
|
|
127
|
-
enabled: globalSettings?.enabled !== false,
|
|
128
|
-
preferredResolution: globalSettings?.enabled
|
|
129
|
-
? globalSettings.dimension
|
|
130
|
-
: undefined,
|
|
131
|
-
participants: Object.fromEntries(
|
|
132
|
-
Object.entries(participants).map(
|
|
133
|
-
([sessionId, participantOverride]) => [
|
|
134
|
-
sessionId,
|
|
135
|
-
{
|
|
136
|
-
enabled: participantOverride?.enabled !== false,
|
|
137
|
-
preferredResolution: participantOverride?.enabled
|
|
138
|
-
? participantOverride.dimension
|
|
139
|
-
: undefined,
|
|
140
|
-
},
|
|
141
|
-
],
|
|
142
|
-
),
|
|
143
|
-
),
|
|
144
|
-
isParticipantVideoEnabled: (sessionId: string) =>
|
|
145
|
-
overrides[sessionId]?.enabled ??
|
|
146
|
-
overrides[globalOverrideKey]?.enabled ??
|
|
147
|
-
true,
|
|
148
|
-
};
|
|
149
|
-
}),
|
|
150
|
-
shareReplay(1),
|
|
151
|
-
);
|
|
41
|
+
private trackSubscriptionManager: TrackSubscriptionManager;
|
|
42
|
+
private blockedAudioTracker: BlockedAudioTracker;
|
|
152
43
|
|
|
153
44
|
/**
|
|
154
45
|
* Creates a new DynascaleManager instance.
|
|
155
46
|
*/
|
|
156
|
-
constructor(
|
|
47
|
+
constructor(
|
|
48
|
+
callState: CallState,
|
|
49
|
+
speaker: SpeakerManager,
|
|
50
|
+
tracer: Tracer,
|
|
51
|
+
trackSubscriptionManager: TrackSubscriptionManager,
|
|
52
|
+
blockedAudioTracker: BlockedAudioTracker,
|
|
53
|
+
) {
|
|
157
54
|
this.callState = callState;
|
|
158
55
|
this.speaker = speaker;
|
|
159
56
|
this.tracer = tracer;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
57
|
+
this.trackSubscriptionManager = trackSubscriptionManager;
|
|
58
|
+
this.blockedAudioTracker = blockedAudioTracker;
|
|
163
59
|
}
|
|
164
60
|
|
|
165
61
|
/**
|
|
166
|
-
*
|
|
62
|
+
* Closes the audio context if it was created.
|
|
167
63
|
*/
|
|
168
64
|
dispose = async () => {
|
|
169
|
-
if (this.pendingSubscriptionsUpdate) {
|
|
170
|
-
clearTimeout(this.pendingSubscriptionsUpdate);
|
|
171
|
-
}
|
|
172
|
-
this.audioBindingsWatchdog?.dispose();
|
|
173
|
-
setCurrentValue(this.blockedAudioElementsSubject, new Set());
|
|
174
65
|
const context = this.audioContext;
|
|
175
66
|
if (context && context.state !== 'closed') {
|
|
176
67
|
document.removeEventListener('click', this.resumeAudioContext);
|
|
@@ -179,174 +70,6 @@ export class DynascaleManager {
|
|
|
179
70
|
}
|
|
180
71
|
};
|
|
181
72
|
|
|
182
|
-
setSfuClient(sfuClient: StreamSfuClient | undefined) {
|
|
183
|
-
this.sfuClient = sfuClient;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
get trackSubscriptions() {
|
|
187
|
-
const subscriptions: TrackSubscriptionDetails[] = [];
|
|
188
|
-
// Use getParticipantsSnapshot() to bypass the observable pipeline
|
|
189
|
-
// and avoid stale data caused by shareReplay with no active subscribers
|
|
190
|
-
const participants = this.callState.getParticipantsSnapshot();
|
|
191
|
-
const videoTrackSubscriptionOverrides =
|
|
192
|
-
this.videoTrackSubscriptionOverridesSubject.getValue();
|
|
193
|
-
for (const p of participants) {
|
|
194
|
-
if (p.isLocalParticipant) continue;
|
|
195
|
-
// NOTE: audio tracks don't have to be requested explicitly
|
|
196
|
-
// as the SFU will implicitly subscribe us to all of them,
|
|
197
|
-
// once they become available.
|
|
198
|
-
if (p.videoDimension && hasVideo(p)) {
|
|
199
|
-
const override =
|
|
200
|
-
videoTrackSubscriptionOverrides[p.sessionId] ??
|
|
201
|
-
videoTrackSubscriptionOverrides[globalOverrideKey];
|
|
202
|
-
|
|
203
|
-
if (override?.enabled !== false) {
|
|
204
|
-
subscriptions.push({
|
|
205
|
-
userId: p.userId,
|
|
206
|
-
sessionId: p.sessionId,
|
|
207
|
-
trackType: TrackType.VIDEO,
|
|
208
|
-
dimension: override?.dimension ?? p.videoDimension,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (p.screenShareDimension && hasScreenShare(p)) {
|
|
213
|
-
subscriptions.push({
|
|
214
|
-
userId: p.userId,
|
|
215
|
-
sessionId: p.sessionId,
|
|
216
|
-
trackType: TrackType.SCREEN_SHARE,
|
|
217
|
-
dimension: p.screenShareDimension,
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
if (hasScreenShareAudio(p)) {
|
|
221
|
-
subscriptions.push({
|
|
222
|
-
userId: p.userId,
|
|
223
|
-
sessionId: p.sessionId,
|
|
224
|
-
trackType: TrackType.SCREEN_SHARE_AUDIO,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return subscriptions;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
get videoTrackSubscriptionOverrides() {
|
|
232
|
-
return getCurrentValue(this.videoTrackSubscriptionOverrides$);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
setVideoTrackSubscriptionOverrides = (
|
|
236
|
-
override: VideoTrackSubscriptionOverride | undefined,
|
|
237
|
-
sessionIds?: string[],
|
|
238
|
-
) => {
|
|
239
|
-
this.tracer.trace('setVideoTrackSubscriptionOverrides', [
|
|
240
|
-
override,
|
|
241
|
-
sessionIds,
|
|
242
|
-
]);
|
|
243
|
-
if (!sessionIds) {
|
|
244
|
-
return setCurrentValue(
|
|
245
|
-
this.videoTrackSubscriptionOverridesSubject,
|
|
246
|
-
override ? { [globalOverrideKey]: override } : {},
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return setCurrentValue(
|
|
251
|
-
this.videoTrackSubscriptionOverridesSubject,
|
|
252
|
-
(overrides) => ({
|
|
253
|
-
...overrides,
|
|
254
|
-
...Object.fromEntries(sessionIds.map((id) => [id, override])),
|
|
255
|
-
}),
|
|
256
|
-
);
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
applyTrackSubscriptions = (
|
|
260
|
-
debounceType: DebounceType = DebounceType.SLOW,
|
|
261
|
-
) => {
|
|
262
|
-
if (this.pendingSubscriptionsUpdate) {
|
|
263
|
-
clearTimeout(this.pendingSubscriptionsUpdate);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const updateSubscriptions = () => {
|
|
267
|
-
this.pendingSubscriptionsUpdate = null;
|
|
268
|
-
this.sfuClient
|
|
269
|
-
?.updateSubscriptions(this.trackSubscriptions)
|
|
270
|
-
.catch((err: unknown) => {
|
|
271
|
-
this.logger.debug(`Failed to update track subscriptions`, err);
|
|
272
|
-
});
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
if (debounceType) {
|
|
276
|
-
this.pendingSubscriptionsUpdate = setTimeout(
|
|
277
|
-
updateSubscriptions,
|
|
278
|
-
debounceType,
|
|
279
|
-
);
|
|
280
|
-
} else {
|
|
281
|
-
updateSubscriptions();
|
|
282
|
-
}
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Will begin tracking the given element for visibility changes within the
|
|
287
|
-
* configured viewport element (`call.setViewport`).
|
|
288
|
-
*
|
|
289
|
-
* @param element the element to track.
|
|
290
|
-
* @param sessionId the session id.
|
|
291
|
-
* @param trackType the kind of video.
|
|
292
|
-
* @returns Untrack.
|
|
293
|
-
*/
|
|
294
|
-
trackElementVisibility = <T extends HTMLElement>(
|
|
295
|
-
element: T,
|
|
296
|
-
sessionId: string,
|
|
297
|
-
trackType: VideoTrackType,
|
|
298
|
-
) => {
|
|
299
|
-
const cleanup = this.viewportTracker.observe(element, (entry) => {
|
|
300
|
-
this.callState.updateParticipant(sessionId, (participant) => {
|
|
301
|
-
const previousVisibilityState =
|
|
302
|
-
participant.viewportVisibilityState ??
|
|
303
|
-
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
304
|
-
|
|
305
|
-
// observer triggers when the element is "moved" to be a fullscreen element
|
|
306
|
-
// keep it VISIBLE if that happens to prevent fullscreen with placeholder
|
|
307
|
-
const isVisible =
|
|
308
|
-
entry.isIntersecting || document.fullscreenElement === element
|
|
309
|
-
? VisibilityState.VISIBLE
|
|
310
|
-
: VisibilityState.INVISIBLE;
|
|
311
|
-
return {
|
|
312
|
-
...participant,
|
|
313
|
-
viewportVisibilityState: {
|
|
314
|
-
...previousVisibilityState,
|
|
315
|
-
[trackType]: isVisible,
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
return () => {
|
|
322
|
-
cleanup();
|
|
323
|
-
// reset visibility state to UNKNOWN upon cleanup
|
|
324
|
-
// so that the layouts that are not actively observed
|
|
325
|
-
// can still function normally (runtime layout switching)
|
|
326
|
-
this.callState.updateParticipant(sessionId, (participant) => {
|
|
327
|
-
const previousVisibilityState =
|
|
328
|
-
participant.viewportVisibilityState ??
|
|
329
|
-
DEFAULT_VIEWPORT_VISIBILITY_STATE;
|
|
330
|
-
return {
|
|
331
|
-
...participant,
|
|
332
|
-
viewportVisibilityState: {
|
|
333
|
-
...previousVisibilityState,
|
|
334
|
-
[trackType]: VisibilityState.UNKNOWN,
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
});
|
|
338
|
-
};
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Sets the viewport element to track bound video elements for visibility.
|
|
343
|
-
*
|
|
344
|
-
* @param element the viewport element.
|
|
345
|
-
*/
|
|
346
|
-
setViewport = <T extends HTMLElement>(element: T) => {
|
|
347
|
-
return this.viewportTracker.setViewport(element);
|
|
348
|
-
};
|
|
349
|
-
|
|
350
73
|
/**
|
|
351
74
|
* Sets whether to use WebAudio API for audio playback.
|
|
352
75
|
* Must be set before joining the call.
|
|
@@ -399,7 +122,7 @@ export class DynascaleManager {
|
|
|
399
122
|
this.callState.updateParticipantTracks(trackType, {
|
|
400
123
|
[sessionId]: { dimension },
|
|
401
124
|
});
|
|
402
|
-
this.
|
|
125
|
+
this.trackSubscriptionManager.apply(debounceType);
|
|
403
126
|
};
|
|
404
127
|
|
|
405
128
|
const participant$ = this.callState.participants$.pipe(
|
|
@@ -521,6 +244,12 @@ export class DynascaleManager {
|
|
|
521
244
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
522
245
|
videoElement.muted = true;
|
|
523
246
|
|
|
247
|
+
const playbackWatchdog = new MediaPlaybackWatchdog({
|
|
248
|
+
element: videoElement,
|
|
249
|
+
kind: 'video',
|
|
250
|
+
tracer: this.tracer,
|
|
251
|
+
});
|
|
252
|
+
|
|
524
253
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
525
254
|
const streamSubscription = participant$
|
|
526
255
|
.pipe(distinctUntilKeyChanged(trackKey))
|
|
@@ -529,14 +258,13 @@ export class DynascaleManager {
|
|
|
529
258
|
if (videoElement.srcObject === source) return;
|
|
530
259
|
videoElement.srcObject = source ?? null;
|
|
531
260
|
if (isSafari() || isFirefox()) {
|
|
532
|
-
setTimeout(() => {
|
|
261
|
+
setTimeout(async () => {
|
|
533
262
|
videoElement.srcObject = source ?? null;
|
|
534
|
-
|
|
263
|
+
try {
|
|
264
|
+
await timeboxed([videoElement.play()], 2000);
|
|
265
|
+
} catch (e) {
|
|
535
266
|
this.logger.warn(`Failed to play stream`, e);
|
|
536
|
-
}
|
|
537
|
-
// we add extra delay until we attempt to force-play
|
|
538
|
-
// the participant's media stream in Firefox and Safari,
|
|
539
|
-
// as they seem to have some timing issues
|
|
267
|
+
}
|
|
540
268
|
}, 25);
|
|
541
269
|
}
|
|
542
270
|
});
|
|
@@ -547,6 +275,7 @@ export class DynascaleManager {
|
|
|
547
275
|
publishedTracksSubscription?.unsubscribe();
|
|
548
276
|
streamSubscription.unsubscribe();
|
|
549
277
|
resizeObserver?.disconnect();
|
|
278
|
+
playbackWatchdog.dispose();
|
|
550
279
|
};
|
|
551
280
|
};
|
|
552
281
|
|
|
@@ -569,8 +298,6 @@ export class DynascaleManager {
|
|
|
569
298
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
570
299
|
if (!participant || participant.isLocalParticipant) return;
|
|
571
300
|
|
|
572
|
-
this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
|
|
573
|
-
|
|
574
301
|
const participant$ = this.callState.participants$.pipe(
|
|
575
302
|
map((ps) => ps.find((p) => p.sessionId === sessionId)),
|
|
576
303
|
takeWhile((p) => !!p),
|
|
@@ -599,6 +326,7 @@ export class DynascaleManager {
|
|
|
599
326
|
|
|
600
327
|
let sourceNode: MediaStreamAudioSourceNode | undefined = undefined;
|
|
601
328
|
let gainNode: GainNode | undefined = undefined;
|
|
329
|
+
let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
|
|
602
330
|
|
|
603
331
|
const isAudioTrack = trackType === 'audioTrack';
|
|
604
332
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
@@ -610,8 +338,10 @@ export class DynascaleManager {
|
|
|
610
338
|
|
|
611
339
|
setTimeout(() => {
|
|
612
340
|
audioElement.srcObject = source ?? null;
|
|
341
|
+
audioWatchdog?.dispose();
|
|
342
|
+
audioWatchdog = undefined;
|
|
613
343
|
if (!source) {
|
|
614
|
-
this.
|
|
344
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
615
345
|
return;
|
|
616
346
|
}
|
|
617
347
|
|
|
@@ -639,10 +369,16 @@ export class DynascaleManager {
|
|
|
639
369
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
640
370
|
if (e.name === 'NotAllowedError') {
|
|
641
371
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
642
|
-
this.
|
|
372
|
+
this.blockedAudioTracker.markBlocked(audioElement, true);
|
|
643
373
|
}
|
|
644
374
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
645
375
|
});
|
|
376
|
+
audioWatchdog = new MediaPlaybackWatchdog({
|
|
377
|
+
element: audioElement,
|
|
378
|
+
kind: 'audio',
|
|
379
|
+
tracer: this.tracer,
|
|
380
|
+
isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
|
|
381
|
+
});
|
|
646
382
|
}
|
|
647
383
|
|
|
648
384
|
const { selectedDevice } = this.speaker.state;
|
|
@@ -669,45 +405,18 @@ export class DynascaleManager {
|
|
|
669
405
|
audioElement.autoplay = true;
|
|
670
406
|
|
|
671
407
|
return () => {
|
|
672
|
-
this.
|
|
673
|
-
this.removeBlockedAudioElement(audioElement);
|
|
408
|
+
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
674
409
|
sinkIdSubscription?.unsubscribe();
|
|
675
410
|
volumeSubscription.unsubscribe();
|
|
676
411
|
updateMediaStreamSubscription.unsubscribe();
|
|
677
412
|
audioElement.srcObject = null;
|
|
678
413
|
sourceNode?.disconnect();
|
|
679
414
|
gainNode?.disconnect();
|
|
415
|
+
audioWatchdog?.dispose();
|
|
416
|
+
audioWatchdog = undefined;
|
|
680
417
|
};
|
|
681
418
|
};
|
|
682
419
|
|
|
683
|
-
/**
|
|
684
|
-
* Plays all audio elements blocked by the browser's autoplay policy.
|
|
685
|
-
* Must be called from within a user gesture (e.g., click handler).
|
|
686
|
-
*
|
|
687
|
-
* @returns a promise that resolves when all blocked elements have been retried.
|
|
688
|
-
*/
|
|
689
|
-
resumeAudio = async () => {
|
|
690
|
-
this.tracer.trace('resumeAudio', null);
|
|
691
|
-
const blocked = new Set<HTMLAudioElement>();
|
|
692
|
-
await Promise.all(
|
|
693
|
-
Array.from(
|
|
694
|
-
getCurrentValue(this.blockedAudioElementsSubject),
|
|
695
|
-
async (el) => {
|
|
696
|
-
try {
|
|
697
|
-
if (el.srcObject) {
|
|
698
|
-
await el.play();
|
|
699
|
-
}
|
|
700
|
-
} catch {
|
|
701
|
-
this.logger.warn(`Can't resume audio for element: `, el);
|
|
702
|
-
blocked.add(el);
|
|
703
|
-
}
|
|
704
|
-
},
|
|
705
|
-
),
|
|
706
|
-
);
|
|
707
|
-
|
|
708
|
-
setCurrentValue(this.blockedAudioElementsSubject, blocked);
|
|
709
|
-
};
|
|
710
|
-
|
|
711
420
|
private getOrCreateAudioContext = (): AudioContext | undefined => {
|
|
712
421
|
if (!this.useWebAudio) return;
|
|
713
422
|
if (this.audioContext) return this.audioContext;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { videoLoggerSystem } from '../logger';
|
|
2
|
+
import { Tracer } from '../stats';
|
|
3
|
+
import { retryInterval, timeboxed } from '../coordinator/connection/utils';
|
|
4
|
+
|
|
5
|
+
type MediaKind = 'audio' | 'video';
|
|
6
|
+
|
|
7
|
+
export type MediaPlaybackWatchdogOptions = {
|
|
8
|
+
element: HTMLMediaElement;
|
|
9
|
+
kind: MediaKind;
|
|
10
|
+
tracer: Tracer;
|
|
11
|
+
isBlocked?: () => boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Watches a single audio or video element and attempts to recover playback
|
|
16
|
+
* after the element transitions to a paused or suspended state unexpectedly.
|
|
17
|
+
*/
|
|
18
|
+
export class MediaPlaybackWatchdog {
|
|
19
|
+
private logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
20
|
+
private readonly kind: MediaKind;
|
|
21
|
+
private readonly isBlocked: () => boolean;
|
|
22
|
+
private element: HTMLMediaElement;
|
|
23
|
+
private tracer: Tracer;
|
|
24
|
+
private controller = new AbortController();
|
|
25
|
+
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
private attempt = 0;
|
|
27
|
+
private disposed = false;
|
|
28
|
+
|
|
29
|
+
constructor(opts: MediaPlaybackWatchdogOptions) {
|
|
30
|
+
this.element = opts.element;
|
|
31
|
+
this.kind = opts.kind;
|
|
32
|
+
this.tracer = opts.tracer;
|
|
33
|
+
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
34
|
+
this.attach();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private attach = () => {
|
|
38
|
+
if (this.disposed) return;
|
|
39
|
+
const { signal } = this.controller;
|
|
40
|
+
this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
|
|
41
|
+
this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
|
|
42
|
+
this.element.addEventListener('playing', this.onPlaying, { signal });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
dispose = () => {
|
|
46
|
+
if (this.disposed) return;
|
|
47
|
+
this.disposed = true;
|
|
48
|
+
this.controller.abort();
|
|
49
|
+
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
50
|
+
this.pendingTimer = undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
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
|
+
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
62
|
+
this.pendingTimer = undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
private onPauseOrSuspend = (event: Event) => {
|
|
66
|
+
if (this.disposed) return;
|
|
67
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
68
|
+
kind: this.kind,
|
|
69
|
+
reason: event.type,
|
|
70
|
+
});
|
|
71
|
+
this.scheduleRecovery();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
private scheduleRecovery = () => {
|
|
75
|
+
if (this.disposed || this.pendingTimer) return;
|
|
76
|
+
const skipReason = this.computeSkipReason();
|
|
77
|
+
if (skipReason) {
|
|
78
|
+
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
79
|
+
kind: this.kind,
|
|
80
|
+
reason: skipReason,
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
85
|
+
this.pendingTimer = setTimeout(this.attemptPlay, delay);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
private computeSkipReason = (): string | undefined => {
|
|
89
|
+
if (this.disposed) return 'disposed';
|
|
90
|
+
if (!this.element.srcObject) return 'noSrc';
|
|
91
|
+
if (this.element.ended) return 'ended';
|
|
92
|
+
if (this.isBlocked()) return 'blocked';
|
|
93
|
+
const HAVE_CURRENT_DATA = 2;
|
|
94
|
+
if (this.element.readyState < HAVE_CURRENT_DATA) return 'notReady';
|
|
95
|
+
if (!this.element.paused) return 'notPaused';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
private attemptPlay = async () => {
|
|
99
|
+
this.pendingTimer = undefined;
|
|
100
|
+
if (this.disposed) return;
|
|
101
|
+
this.attempt += 1;
|
|
102
|
+
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
103
|
+
kind: this.kind,
|
|
104
|
+
attempt: this.attempt,
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
await timeboxed([this.element.play()], 2000);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (this.disposed) return;
|
|
110
|
+
this.logger.warn(`Failed to recover ${this.kind} playback`, err);
|
|
111
|
+
if (this.attempt >= 10) {
|
|
112
|
+
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
113
|
+
kind: this.kind,
|
|
114
|
+
attempts: this.attempt,
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.scheduleRecovery();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|