@stream-io/video-client 1.61.0 → 1.61.2
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 +15 -0
- package/dist/index.browser.es.js +292 -154
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +292 -154
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +292 -154
- package/dist/index.es.js.map +1 -1
- package/dist/src/coordinator/connection/types.d.ts +1 -1
- package/dist/src/devices/DeviceManager.d.ts +6 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +0 -27
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +7 -2
- package/dist/src/helpers/DynascaleManager.d.ts +15 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +18 -0
- package/dist/src/helpers/mediaInterruptions.d.ts +14 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -1
- package/dist/src/ringing/reconcileRingState.d.ts +8 -7
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -13
- package/dist/src/stats/rtc/StatsTracer.d.ts +6 -6
- package/dist/src/stats/rtc/types.d.ts +0 -11
- package/package.json +1 -1
- package/src/Call.ts +0 -3
- package/src/StreamVideoClient.ts +8 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +136 -0
- package/src/coordinator/connection/types.ts +1 -1
- package/src/devices/DeviceManager.ts +20 -1
- package/src/devices/__tests__/CameraManager.test.ts +50 -1
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +10 -6
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -53
- package/src/helpers/AudioBindingsWatchdog.ts +91 -35
- package/src/helpers/DynascaleManager.ts +84 -9
- package/src/helpers/MediaPlaybackWatchdog.ts +55 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +247 -9
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +140 -0
- package/src/helpers/__tests__/mediaInterruptions.test.ts +131 -0
- package/src/helpers/mediaInterruptions.ts +37 -0
- package/src/reporting/ClientEventReporter.ts +8 -3
- package/src/reporting/__tests__/ClientEventReporter.test.ts +2 -2
- package/src/ringing/RingStatePoller.ts +1 -1
- package/src/ringing/__tests__/RingStatePoller.test.ts +1 -1
- package/src/ringing/__tests__/reconcileRingState.test.ts +33 -0
- package/src/ringing/reconcileRingState.ts +18 -10
- package/src/rtc/BasePeerConnection.ts +6 -1
- package/src/rtc/__tests__/Publisher.test.ts +50 -0
- package/src/stats/SfuStatsReporter.ts +19 -93
- package/src/stats/__tests__/SfuStatsReporter.test.ts +2 -18
- package/src/stats/rtc/StatsTracer.ts +6 -6
- package/src/stats/rtc/types.ts +0 -13
|
@@ -12,7 +12,6 @@ import { PerformanceStats } from '../models/models';
|
|
|
12
12
|
import { RTMPIngress } from '../models/models';
|
|
13
13
|
import { AppleState } from '../models/models';
|
|
14
14
|
import { AndroidState } from '../models/models';
|
|
15
|
-
import { InputDevices } from '../models/models';
|
|
16
15
|
import { RemoteOutboundRtp } from '../models/models';
|
|
17
16
|
import { RemoteInboundRtp } from '../models/models';
|
|
18
17
|
import { OutboundRtp } from '../models/models';
|
|
@@ -135,14 +134,6 @@ export interface SendStatsRequest {
|
|
|
135
134
|
* @generated from protobuf field: string session_id = 1;
|
|
136
135
|
*/
|
|
137
136
|
sessionId: string;
|
|
138
|
-
/**
|
|
139
|
-
* @generated from protobuf field: string subscriber_stats = 2;
|
|
140
|
-
*/
|
|
141
|
-
subscriberStats: string;
|
|
142
|
-
/**
|
|
143
|
-
* @generated from protobuf field: string publisher_stats = 3;
|
|
144
|
-
*/
|
|
145
|
-
publisherStats: string;
|
|
146
137
|
/**
|
|
147
138
|
* @generated from protobuf field: string webrtc_version = 4;
|
|
148
139
|
*/
|
|
@@ -155,14 +146,6 @@ export interface SendStatsRequest {
|
|
|
155
146
|
* @generated from protobuf field: string sdk_version = 6;
|
|
156
147
|
*/
|
|
157
148
|
sdkVersion: string;
|
|
158
|
-
/**
|
|
159
|
-
* @generated from protobuf field: stream.video.sfu.models.InputDevices audio_devices = 7;
|
|
160
|
-
*/
|
|
161
|
-
audioDevices?: InputDevices;
|
|
162
|
-
/**
|
|
163
|
-
* @generated from protobuf field: stream.video.sfu.models.InputDevices video_devices = 8;
|
|
164
|
-
*/
|
|
165
|
-
videoDevices?: InputDevices;
|
|
166
149
|
/**
|
|
167
150
|
* @generated from protobuf oneof: device_state
|
|
168
151
|
*/
|
|
@@ -192,16 +175,6 @@ export interface SendStatsRequest {
|
|
|
192
175
|
* @generated from protobuf field: stream.video.sfu.models.RTMPIngress rtmp = 12;
|
|
193
176
|
*/
|
|
194
177
|
rtmp?: RTMPIngress;
|
|
195
|
-
/**
|
|
196
|
-
* @deprecated
|
|
197
|
-
* @generated from protobuf field: string subscriber_rtc_stats = 13 [deprecated = true];
|
|
198
|
-
*/
|
|
199
|
-
subscriberRtcStats: string;
|
|
200
|
-
/**
|
|
201
|
-
* @deprecated
|
|
202
|
-
* @generated from protobuf field: string publisher_rtc_stats = 14 [deprecated = true];
|
|
203
|
-
*/
|
|
204
|
-
publisherRtcStats: string;
|
|
205
178
|
/**
|
|
206
179
|
* @generated from protobuf field: string rtc_stats = 15;
|
|
207
180
|
*/
|
|
@@ -600,18 +573,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
|
|
|
600
573
|
constructor() {
|
|
601
574
|
super('stream.video.sfu.signal.SendStatsRequest', [
|
|
602
575
|
{ no: 1, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
603
|
-
{
|
|
604
|
-
no: 2,
|
|
605
|
-
name: 'subscriber_stats',
|
|
606
|
-
kind: 'scalar',
|
|
607
|
-
T: 9 /*ScalarType.STRING*/,
|
|
608
|
-
},
|
|
609
|
-
{
|
|
610
|
-
no: 3,
|
|
611
|
-
name: 'publisher_stats',
|
|
612
|
-
kind: 'scalar',
|
|
613
|
-
T: 9 /*ScalarType.STRING*/,
|
|
614
|
-
},
|
|
615
576
|
{
|
|
616
577
|
no: 4,
|
|
617
578
|
name: 'webrtc_version',
|
|
@@ -625,8 +586,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
|
|
|
625
586
|
kind: 'scalar',
|
|
626
587
|
T: 9 /*ScalarType.STRING*/,
|
|
627
588
|
},
|
|
628
|
-
{ no: 7, name: 'audio_devices', kind: 'message', T: () => InputDevices },
|
|
629
|
-
{ no: 8, name: 'video_devices', kind: 'message', T: () => InputDevices },
|
|
630
589
|
{
|
|
631
590
|
no: 9,
|
|
632
591
|
name: 'android',
|
|
@@ -643,18 +602,6 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
|
|
|
643
602
|
},
|
|
644
603
|
{ no: 11, name: 'telemetry', kind: 'message', T: () => Telemetry },
|
|
645
604
|
{ no: 12, name: 'rtmp', kind: 'message', T: () => RTMPIngress },
|
|
646
|
-
{
|
|
647
|
-
no: 13,
|
|
648
|
-
name: 'subscriber_rtc_stats',
|
|
649
|
-
kind: 'scalar',
|
|
650
|
-
T: 9 /*ScalarType.STRING*/,
|
|
651
|
-
},
|
|
652
|
-
{
|
|
653
|
-
no: 14,
|
|
654
|
-
name: 'publisher_rtc_stats',
|
|
655
|
-
kind: 'scalar',
|
|
656
|
-
T: 9 /*ScalarType.STRING*/,
|
|
657
|
-
},
|
|
658
605
|
{ no: 15, name: 'rtc_stats', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
|
|
659
606
|
{
|
|
660
607
|
no: 16,
|
|
@@ -10,12 +10,43 @@ const toBindingKey = (
|
|
|
10
10
|
trackType: AudioTrackType = 'audioTrack',
|
|
11
11
|
) => `${sessionId}/${trackType}`;
|
|
12
12
|
|
|
13
|
+
type AudioElementState = {
|
|
14
|
+
paused: boolean;
|
|
15
|
+
muted: boolean;
|
|
16
|
+
volume: number;
|
|
17
|
+
readyState: number;
|
|
18
|
+
sinkId?: string;
|
|
19
|
+
track: { enabled: boolean; muted: boolean; readyState: string } | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const toElementState = (element: HTMLAudioElement): AudioElementState => {
|
|
23
|
+
const stream = element.srcObject as MediaStream | null;
|
|
24
|
+
const [track] = stream?.getAudioTracks?.() ?? [];
|
|
25
|
+
return {
|
|
26
|
+
paused: element.paused,
|
|
27
|
+
muted: element.muted,
|
|
28
|
+
volume: Math.round(element.volume * 100) / 100,
|
|
29
|
+
readyState: element.readyState,
|
|
30
|
+
sinkId: element.sinkId,
|
|
31
|
+
track: track
|
|
32
|
+
? {
|
|
33
|
+
enabled: track.enabled,
|
|
34
|
+
muted: track.muted,
|
|
35
|
+
readyState: track.readyState,
|
|
36
|
+
}
|
|
37
|
+
: null,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
13
41
|
/**
|
|
14
42
|
* Tracks audio element bindings and periodically warns about
|
|
15
43
|
* remote participants whose audio streams have no bound element.
|
|
44
|
+
* On the same tick, it traces the playback state of the bound elements,
|
|
45
|
+
* limited to the bindings whose state changed since the previous trace.
|
|
16
46
|
*/
|
|
17
47
|
export class AudioBindingsWatchdog {
|
|
18
48
|
private bindings = new Map<string, HTMLAudioElement>();
|
|
49
|
+
private tracedStates = new Map<string, string>();
|
|
19
50
|
private enabled = true;
|
|
20
51
|
private watchdogInterval?: NodeJS.Timeout;
|
|
21
52
|
private readonly unsubscribeCallingState: () => void;
|
|
@@ -30,7 +61,6 @@ export class AudioBindingsWatchdog {
|
|
|
30
61
|
this.unsubscribeCallingState = createSubscription(
|
|
31
62
|
state.callingState$,
|
|
32
63
|
(callingState) => {
|
|
33
|
-
if (!this.enabled) return;
|
|
34
64
|
if (callingState !== CallingState.JOINED) {
|
|
35
65
|
this.stop();
|
|
36
66
|
} else {
|
|
@@ -68,14 +98,14 @@ export class AudioBindingsWatchdog {
|
|
|
68
98
|
};
|
|
69
99
|
|
|
70
100
|
/**
|
|
71
|
-
* Enables or disables the
|
|
72
|
-
*
|
|
101
|
+
* Enables or disables the dangling binding warnings.
|
|
102
|
+
* Bindings are still tracked and their playback state is still traced.
|
|
73
103
|
*/
|
|
74
104
|
setEnabled = (enabled: boolean) => {
|
|
75
105
|
this.enabled = enabled;
|
|
76
106
|
if (enabled) {
|
|
77
107
|
this.start();
|
|
78
|
-
} else {
|
|
108
|
+
} else if (this.state.callingState !== CallingState.JOINED) {
|
|
79
109
|
this.stop();
|
|
80
110
|
}
|
|
81
111
|
};
|
|
@@ -86,45 +116,71 @@ export class AudioBindingsWatchdog {
|
|
|
86
116
|
dispose = () => {
|
|
87
117
|
this.stop();
|
|
88
118
|
this.bindings.clear();
|
|
119
|
+
this.tracedStates.clear();
|
|
89
120
|
this.unsubscribeCallingState();
|
|
90
121
|
};
|
|
91
122
|
|
|
92
123
|
private start = () => {
|
|
93
124
|
clearInterval(this.watchdogInterval);
|
|
94
125
|
this.watchdogInterval = setInterval(() => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
danglingUserIds.push(userId);
|
|
118
|
-
}
|
|
126
|
+
if (this.enabled) this.warnAboutDanglingBindings();
|
|
127
|
+
this.traceBindingStates();
|
|
128
|
+
}, 3000);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
private warnAboutDanglingBindings = () => {
|
|
132
|
+
const danglingUserIds: string[] = [];
|
|
133
|
+
for (const p of this.state.participants) {
|
|
134
|
+
if (p.isLocalParticipant) continue;
|
|
135
|
+
const {
|
|
136
|
+
audioStream,
|
|
137
|
+
screenShareAudioStream,
|
|
138
|
+
sessionId,
|
|
139
|
+
userId,
|
|
140
|
+
publishedTracks,
|
|
141
|
+
} = p;
|
|
142
|
+
if (
|
|
143
|
+
audioStream &&
|
|
144
|
+
publishedTracks.includes(TrackType.AUDIO) &&
|
|
145
|
+
!this.bindings.has(toBindingKey(sessionId))
|
|
146
|
+
) {
|
|
147
|
+
danglingUserIds.push(userId);
|
|
119
148
|
}
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
);
|
|
149
|
+
if (
|
|
150
|
+
screenShareAudioStream &&
|
|
151
|
+
publishedTracks.includes(TrackType.SCREEN_SHARE_AUDIO) &&
|
|
152
|
+
!this.bindings.has(toBindingKey(sessionId, 'screenShareAudioTrack'))
|
|
153
|
+
) {
|
|
154
|
+
danglingUserIds.push(userId);
|
|
126
155
|
}
|
|
127
|
-
}
|
|
156
|
+
}
|
|
157
|
+
if (danglingUserIds.length > 0) {
|
|
158
|
+
const key = 'audioBinding.danglingWarning';
|
|
159
|
+
this.tracer.traceOnce(key, key, danglingUserIds);
|
|
160
|
+
this.logger.warn(
|
|
161
|
+
`Dangling audio bindings detected. Did you forget to bind the audio element? user_ids: ${danglingUserIds}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
private traceBindingStates = () => {
|
|
167
|
+
const changes: Record<string, AudioElementState | null> = {};
|
|
168
|
+
let hasChanges = false;
|
|
169
|
+
for (const [key, element] of this.bindings) {
|
|
170
|
+
const state = toElementState(element);
|
|
171
|
+
const serialized = JSON.stringify(state);
|
|
172
|
+
if (this.tracedStates.get(key) === serialized) continue;
|
|
173
|
+
this.tracedStates.set(key, serialized);
|
|
174
|
+
changes[key] = state;
|
|
175
|
+
hasChanges = true;
|
|
176
|
+
}
|
|
177
|
+
for (const key of this.tracedStates.keys()) {
|
|
178
|
+
if (this.bindings.has(key)) continue;
|
|
179
|
+
this.tracedStates.delete(key);
|
|
180
|
+
changes[key] = null;
|
|
181
|
+
hasChanges = true;
|
|
182
|
+
}
|
|
183
|
+
if (hasChanges) this.tracer.trace('audioBinding.state', changes);
|
|
128
184
|
};
|
|
129
185
|
|
|
130
186
|
private stop = () => {
|
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
takeWhile,
|
|
15
15
|
} from 'rxjs';
|
|
16
16
|
import type { BlockedAudioTracker } from './BlockedAudioTracker';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
MediaPlaybackWatchdog,
|
|
19
|
+
type MediaPlaybackWatchdogOptions,
|
|
20
|
+
} from './MediaPlaybackWatchdog';
|
|
21
|
+
import { watchMediaInterruptions } from './mediaInterruptions';
|
|
18
22
|
import type { TrackSubscriptionManager } from './TrackSubscriptionManager';
|
|
19
23
|
import { isFirefox, isSafari } from './browsers';
|
|
20
24
|
import { hasScreenShare, hasVideo } from './participantUtils';
|
|
@@ -37,6 +41,9 @@ export class DynascaleManager {
|
|
|
37
41
|
private readonly tracer: Tracer;
|
|
38
42
|
private useWebAudio = false;
|
|
39
43
|
private audioContext: AudioContext | undefined;
|
|
44
|
+
private playbackWatchdogs = new Set<MediaPlaybackWatchdog>();
|
|
45
|
+
private stopWatchingInterruptions: (() => void) | undefined;
|
|
46
|
+
private stopTracingAudioSession: (() => void) | undefined;
|
|
40
47
|
|
|
41
48
|
private trackSubscriptionManager: TrackSubscriptionManager;
|
|
42
49
|
private blockedAudioTracker: BlockedAudioTracker;
|
|
@@ -62,6 +69,10 @@ export class DynascaleManager {
|
|
|
62
69
|
* Closes the audio context if it was created.
|
|
63
70
|
*/
|
|
64
71
|
dispose = async () => {
|
|
72
|
+
this.stopWatchingInterruptions?.();
|
|
73
|
+
this.stopWatchingInterruptions = undefined;
|
|
74
|
+
this.stopTracingAudioSession?.();
|
|
75
|
+
this.stopTracingAudioSession = undefined;
|
|
65
76
|
const context = this.audioContext;
|
|
66
77
|
if (context && context.state !== 'closed') {
|
|
67
78
|
document.removeEventListener('click', this.resumeAudioContext);
|
|
@@ -244,10 +255,12 @@ export class DynascaleManager {
|
|
|
244
255
|
// https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
|
|
245
256
|
videoElement.muted = true;
|
|
246
257
|
|
|
247
|
-
const playbackWatchdog =
|
|
258
|
+
const playbackWatchdog = this.createPlaybackWatchdog({
|
|
248
259
|
element: videoElement,
|
|
249
260
|
kind: 'video',
|
|
250
261
|
tracer: this.tracer,
|
|
262
|
+
sessionId,
|
|
263
|
+
trackType,
|
|
251
264
|
});
|
|
252
265
|
|
|
253
266
|
const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
|
|
@@ -275,7 +288,7 @@ export class DynascaleManager {
|
|
|
275
288
|
publishedTracksSubscription?.unsubscribe();
|
|
276
289
|
streamSubscription.unsubscribe();
|
|
277
290
|
resizeObserver?.disconnect();
|
|
278
|
-
|
|
291
|
+
this.disposePlaybackWatchdog(playbackWatchdog);
|
|
279
292
|
};
|
|
280
293
|
};
|
|
281
294
|
|
|
@@ -297,6 +310,7 @@ export class DynascaleManager {
|
|
|
297
310
|
) => {
|
|
298
311
|
const participant = this.callState.findParticipantBySessionId(sessionId);
|
|
299
312
|
if (!participant || participant.isLocalParticipant) return;
|
|
313
|
+
this.traceAudioSessionState();
|
|
300
314
|
|
|
301
315
|
const participant$ = this.callState.participants$.pipe(
|
|
302
316
|
map((ps) => ps.find((p) => p.sessionId === sessionId)),
|
|
@@ -327,8 +341,10 @@ export class DynascaleManager {
|
|
|
327
341
|
let sourceNode: MediaStreamAudioSourceNode | undefined = undefined;
|
|
328
342
|
let gainNode: GainNode | undefined = undefined;
|
|
329
343
|
let audioWatchdog: MediaPlaybackWatchdog | undefined = undefined;
|
|
344
|
+
let blockedByGiveUp = false;
|
|
330
345
|
|
|
331
346
|
const clearBlockedAudio = () => {
|
|
347
|
+
blockedByGiveUp = false;
|
|
332
348
|
if (!this.blockedAudioTracker.isBlocked(audioElement)) return;
|
|
333
349
|
this.blockedAudioTracker.markBlocked(audioElement, false);
|
|
334
350
|
};
|
|
@@ -336,15 +352,18 @@ export class DynascaleManager {
|
|
|
336
352
|
|
|
337
353
|
const isAudioTrack = trackType === 'audioTrack';
|
|
338
354
|
const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
|
|
355
|
+
let sourceChangeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
339
356
|
const updateMediaStreamSubscription = participant$
|
|
340
357
|
.pipe(distinctUntilKeyChanged(trackKey))
|
|
341
358
|
.subscribe((p) => {
|
|
342
359
|
const source = isAudioTrack ? p.audioStream : p.screenShareAudioStream;
|
|
343
360
|
if (audioElement.srcObject === source) return;
|
|
344
361
|
|
|
345
|
-
|
|
362
|
+
clearTimeout(sourceChangeTimer);
|
|
363
|
+
sourceChangeTimer = setTimeout(() => {
|
|
364
|
+
sourceChangeTimer = undefined;
|
|
346
365
|
audioElement.srcObject = source ?? null;
|
|
347
|
-
audioWatchdog
|
|
366
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
348
367
|
audioWatchdog = undefined;
|
|
349
368
|
if (!source) {
|
|
350
369
|
clearBlockedAudio();
|
|
@@ -375,6 +394,7 @@ export class DynascaleManager {
|
|
|
375
394
|
this.tracer.trace('audioPlaybackError', e.message);
|
|
376
395
|
if (e.name === 'NotAllowedError') {
|
|
377
396
|
this.tracer.trace('audioPlaybackBlocked', null);
|
|
397
|
+
blockedByGiveUp = false;
|
|
378
398
|
this.blockedAudioTracker.markBlocked(
|
|
379
399
|
audioElement,
|
|
380
400
|
true,
|
|
@@ -383,11 +403,25 @@ export class DynascaleManager {
|
|
|
383
403
|
}
|
|
384
404
|
this.logger.warn(`Failed to play audio stream`, e);
|
|
385
405
|
});
|
|
386
|
-
audioWatchdog =
|
|
406
|
+
audioWatchdog = this.createPlaybackWatchdog({
|
|
387
407
|
element: audioElement,
|
|
388
408
|
kind: 'audio',
|
|
389
409
|
tracer: this.tracer,
|
|
390
|
-
|
|
410
|
+
sessionId,
|
|
411
|
+
trackType,
|
|
412
|
+
// only an autoplay-policy block needs a user gesture; a give-up
|
|
413
|
+
// block is for the UI and must not stop a re-armed cycle
|
|
414
|
+
isBlocked: () =>
|
|
415
|
+
!blockedByGiveUp &&
|
|
416
|
+
this.blockedAudioTracker.isBlocked(audioElement),
|
|
417
|
+
onGiveUp: () => {
|
|
418
|
+
blockedByGiveUp = true;
|
|
419
|
+
this.blockedAudioTracker.markBlocked(
|
|
420
|
+
audioElement,
|
|
421
|
+
true,
|
|
422
|
+
sessionId,
|
|
423
|
+
);
|
|
424
|
+
},
|
|
391
425
|
});
|
|
392
426
|
}
|
|
393
427
|
|
|
@@ -420,14 +454,56 @@ export class DynascaleManager {
|
|
|
420
454
|
sinkIdSubscription?.unsubscribe();
|
|
421
455
|
volumeSubscription.unsubscribe();
|
|
422
456
|
updateMediaStreamSubscription.unsubscribe();
|
|
457
|
+
clearTimeout(sourceChangeTimer);
|
|
423
458
|
audioElement.srcObject = null;
|
|
424
459
|
sourceNode?.disconnect();
|
|
425
460
|
gainNode?.disconnect();
|
|
426
|
-
audioWatchdog
|
|
461
|
+
this.disposePlaybackWatchdog(audioWatchdog);
|
|
427
462
|
audioWatchdog = undefined;
|
|
428
463
|
};
|
|
429
464
|
};
|
|
430
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Creates a playback watchdog that is re-armed whenever a media
|
|
468
|
+
* interruption (backgrounding, phone call, Siri) ends.
|
|
469
|
+
*/
|
|
470
|
+
private createPlaybackWatchdog = (opts: MediaPlaybackWatchdogOptions) => {
|
|
471
|
+
const watchdog = new MediaPlaybackWatchdog(opts);
|
|
472
|
+
this.playbackWatchdogs.add(watchdog);
|
|
473
|
+
this.stopWatchingInterruptions ??= watchMediaInterruptions(
|
|
474
|
+
this.rearmPlayback,
|
|
475
|
+
);
|
|
476
|
+
return watchdog;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
private disposePlaybackWatchdog = (watchdog?: MediaPlaybackWatchdog) => {
|
|
480
|
+
if (!watchdog) return;
|
|
481
|
+
watchdog.dispose();
|
|
482
|
+
this.playbackWatchdogs.delete(watchdog);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
private rearmPlayback = () => {
|
|
486
|
+
this.tracer.trace('mediaPlayback.rearm', this.playbackWatchdogs.size);
|
|
487
|
+
this.playbackWatchdogs.forEach((watchdog) => watchdog.rearm());
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Traces the WebKit audio session state (initial value and changes) in
|
|
492
|
+
* both playback modes, as it explains most iOS playback interruptions.
|
|
493
|
+
*/
|
|
494
|
+
private traceAudioSessionState = () => {
|
|
495
|
+
const audioSession = navigator.audioSession;
|
|
496
|
+
if (!audioSession || this.stopTracingAudioSession) return;
|
|
497
|
+
const trace = () => {
|
|
498
|
+
this.tracer.trace('audioSession.state', audioSession.state);
|
|
499
|
+
};
|
|
500
|
+
trace();
|
|
501
|
+
audioSession.addEventListener('statechange', trace);
|
|
502
|
+
this.stopTracingAudioSession = () => {
|
|
503
|
+
audioSession.removeEventListener('statechange', trace);
|
|
504
|
+
};
|
|
505
|
+
};
|
|
506
|
+
|
|
431
507
|
private getOrCreateAudioContext = (): AudioContext | undefined => {
|
|
432
508
|
if (!this.useWebAudio) return;
|
|
433
509
|
if (this.audioContext) return this.audioContext;
|
|
@@ -450,7 +526,6 @@ export class DynascaleManager {
|
|
|
450
526
|
|
|
451
527
|
let isSessionInterrupted = false;
|
|
452
528
|
audioSession.addEventListener('statechange', () => {
|
|
453
|
-
this.tracer.trace('audioSession.state', audioSession.state);
|
|
454
529
|
if (audioSession.state === 'interrupted') {
|
|
455
530
|
isSessionInterrupted = true;
|
|
456
531
|
} else if (isSessionInterrupted) {
|
|
@@ -8,7 +8,10 @@ export type MediaPlaybackWatchdogOptions = {
|
|
|
8
8
|
element: HTMLMediaElement;
|
|
9
9
|
kind: MediaKind;
|
|
10
10
|
tracer: Tracer;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
trackType?: string;
|
|
11
13
|
isBlocked?: () => boolean;
|
|
14
|
+
onGiveUp?: () => void;
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -18,7 +21,13 @@ export type MediaPlaybackWatchdogOptions = {
|
|
|
18
21
|
export class MediaPlaybackWatchdog {
|
|
19
22
|
private logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
|
|
20
23
|
private readonly kind: MediaKind;
|
|
24
|
+
private readonly traceInfo: {
|
|
25
|
+
kind: MediaKind;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
trackType?: string;
|
|
28
|
+
};
|
|
21
29
|
private readonly isBlocked: () => boolean;
|
|
30
|
+
private readonly onGiveUp: () => void;
|
|
22
31
|
private element: HTMLMediaElement;
|
|
23
32
|
private tracer: Tracer;
|
|
24
33
|
private controller = new AbortController();
|
|
@@ -31,8 +40,14 @@ export class MediaPlaybackWatchdog {
|
|
|
31
40
|
constructor(opts: MediaPlaybackWatchdogOptions) {
|
|
32
41
|
this.element = opts.element;
|
|
33
42
|
this.kind = opts.kind;
|
|
43
|
+
this.traceInfo = {
|
|
44
|
+
kind: opts.kind,
|
|
45
|
+
sessionId: opts.sessionId,
|
|
46
|
+
trackType: opts.trackType,
|
|
47
|
+
};
|
|
34
48
|
this.tracer = opts.tracer;
|
|
35
49
|
this.isBlocked = opts.isBlocked ?? (() => false);
|
|
50
|
+
this.onGiveUp = opts.onGiveUp ?? (() => {});
|
|
36
51
|
this.attach();
|
|
37
52
|
}
|
|
38
53
|
|
|
@@ -51,6 +66,22 @@ export class MediaPlaybackWatchdog {
|
|
|
51
66
|
this.clearTimers();
|
|
52
67
|
};
|
|
53
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Starts a fresh recovery cycle, e.g. after a media interruption ended.
|
|
71
|
+
* Unlike a regular pause-triggered recovery, it also tries elements that
|
|
72
|
+
* were skipped as blocked or not ready, since those conditions may have
|
|
73
|
+
* cleared together with the interruption.
|
|
74
|
+
*/
|
|
75
|
+
rearm = () => {
|
|
76
|
+
if (this.disposed) return;
|
|
77
|
+
this.clearTimers();
|
|
78
|
+
this.attempt = 0;
|
|
79
|
+
this.stopped = false;
|
|
80
|
+
const { paused, srcObject, ended } = this.element;
|
|
81
|
+
if (!paused || !srcObject || ended) return;
|
|
82
|
+
this.pendingTimer = setTimeout(this.attemptPlay, 0);
|
|
83
|
+
};
|
|
84
|
+
|
|
54
85
|
private clearTimers = () => {
|
|
55
86
|
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
56
87
|
this.pendingTimer = undefined;
|
|
@@ -69,17 +100,34 @@ export class MediaPlaybackWatchdog {
|
|
|
69
100
|
this.settleTimer = undefined;
|
|
70
101
|
if (this.element.paused) return;
|
|
71
102
|
this.tracer.trace('mediaPlayback.recover.success', {
|
|
72
|
-
|
|
103
|
+
...this.traceInfo,
|
|
73
104
|
attempts: this.attempt,
|
|
74
105
|
});
|
|
75
106
|
this.attempt = 0;
|
|
76
107
|
this.stopped = false;
|
|
77
108
|
};
|
|
78
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Media elements backed by a MediaStream fire `suspend` routinely (after
|
|
112
|
+
* the initial load, on source swaps, on layer switches), so a `suspend`
|
|
113
|
+
* that needs no recovery is ignored without a trace. An explicit `pause`
|
|
114
|
+
* is rare and always traced, along with the reason recovery was skipped.
|
|
115
|
+
*/
|
|
79
116
|
private onPauseOrSuspend = (event: Event) => {
|
|
80
|
-
if (this.disposed || this.stopped) return;
|
|
117
|
+
if (this.disposed || this.stopped || this.pendingTimer) return;
|
|
118
|
+
const skipReason = this.computeSkipReason();
|
|
119
|
+
if (skipReason) {
|
|
120
|
+
if (event.type === 'pause') {
|
|
121
|
+
this.tracer.trace('mediaPlayback.paused', {
|
|
122
|
+
...this.traceInfo,
|
|
123
|
+
reason: event.type,
|
|
124
|
+
skipped: skipReason,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
81
129
|
this.tracer.trace('mediaPlayback.paused', {
|
|
82
|
-
|
|
130
|
+
...this.traceInfo,
|
|
83
131
|
reason: event.type,
|
|
84
132
|
});
|
|
85
133
|
if (this.settleTimer) clearTimeout(this.settleTimer);
|
|
@@ -92,7 +140,7 @@ export class MediaPlaybackWatchdog {
|
|
|
92
140
|
const skipReason = this.computeSkipReason();
|
|
93
141
|
if (skipReason) {
|
|
94
142
|
this.tracer.trace('mediaPlayback.recover.skipped', {
|
|
95
|
-
|
|
143
|
+
...this.traceInfo,
|
|
96
144
|
reason: skipReason,
|
|
97
145
|
});
|
|
98
146
|
return;
|
|
@@ -102,9 +150,10 @@ export class MediaPlaybackWatchdog {
|
|
|
102
150
|
this.stopped = true;
|
|
103
151
|
this.clearTimers();
|
|
104
152
|
this.tracer.trace('mediaPlayback.recover.giveUp', {
|
|
105
|
-
|
|
153
|
+
...this.traceInfo,
|
|
106
154
|
attempts: this.attempt,
|
|
107
155
|
});
|
|
156
|
+
this.onGiveUp();
|
|
108
157
|
return;
|
|
109
158
|
}
|
|
110
159
|
const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
|
|
@@ -127,7 +176,7 @@ export class MediaPlaybackWatchdog {
|
|
|
127
176
|
if (this.disposed) return;
|
|
128
177
|
this.attempt += 1;
|
|
129
178
|
this.tracer.trace('mediaPlayback.recover.attempt', {
|
|
130
|
-
|
|
179
|
+
...this.traceInfo,
|
|
131
180
|
attempt: this.attempt,
|
|
132
181
|
});
|
|
133
182
|
try {
|