@stream-io/video-client 1.56.0 → 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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.56.0",
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.16.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.34.0",
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.2",
50
- "@stream-io/node-sdk": "^0.7.59",
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.3",
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.7",
56
+ "@vitest/coverage-v8": "^4.1.10",
57
57
  "dotenv": "^16.6.1",
58
- "happy-dom": "^20.9.0",
59
- "prettier": "^3.8.3",
58
+ "happy-dom": "^20.11.1",
59
+ "prettier": "^3.9.6",
60
60
  "rimraf": "^6.1.3",
61
- "rollup": "^4.60.4",
61
+ "rollup": "^4.62.4",
62
62
  "typescript": "^6.0.3",
63
- "vitest": "^4.1.7",
63
+ "vitest": "^4.1.10",
64
64
  "vitest-mock-extended": "^4.0.0"
65
65
  }
66
66
  }
@@ -16,8 +16,7 @@ import { settled, withoutConcurrency } from '../../helpers/concurrency';
16
16
  import { ClientEventReporter } from '../../reporting';
17
17
 
18
18
  let speechActivityCallback:
19
- | ((state: { isSoundDetected: boolean }) => void)
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';
@@ -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
  };
@@ -28,20 +28,33 @@ const createMediaElement = (
28
28
  state: FakeMediaState = {},
29
29
  ) => {
30
30
  const el = document.createElement(kind) as HTMLMediaElement;
31
- Object.defineProperty(el, 'srcObject', { writable: true });
31
+ Object.defineProperty(el, 'srcObject', {
32
+ value: 'srcObject' in state ? state.srcObject : new MediaStream(),
33
+ writable: true,
34
+ });
32
35
  Object.defineProperty(el, 'paused', { writable: true, configurable: true });
33
36
  Object.defineProperty(el, 'readyState', {
37
+ value: state.readyState ?? 4,
38
+ writable: true,
39
+ configurable: true,
40
+ });
41
+ Object.defineProperty(el, 'ended', {
42
+ value: state.ended ?? false,
34
43
  writable: true,
35
44
  configurable: true,
36
45
  });
37
- Object.defineProperty(el, 'ended', { writable: true, configurable: true });
38
- el.srcObject = 'srcObject' in state ? state.srcObject : new MediaStream();
39
- el.paused = state.paused ?? true;
40
- el.readyState = state.readyState ?? 4;
41
- el.ended = state.ended ?? false;
46
+ setPaused(el, state.paused ?? true);
42
47
  return el;
43
48
  };
44
49
 
50
+ const setPaused = (el: HTMLMediaElement, paused: boolean) => {
51
+ Object.defineProperty(el, 'paused', {
52
+ value: paused,
53
+ writable: true,
54
+ configurable: true,
55
+ });
56
+ };
57
+
45
58
  type SetupOpts = {
46
59
  kind?: 'audio' | 'video';
47
60
  state?: FakeMediaState;
@@ -111,15 +124,131 @@ describe('MediaPlaybackWatchdog', () => {
111
124
  await vi.advanceTimersByTimeAsync(6000);
112
125
  expect(play).toHaveBeenCalledTimes(2);
113
126
 
114
- // Simulate the element actually starting to play.
127
+ // Simulate the element actually starting to play, and staying up long
128
+ // enough for the recovery to count as settled.
129
+ setPaused(el, false);
115
130
  el.dispatchEvent(new Event('playing'));
131
+ await vi.advanceTimersByTimeAsync(1000);
116
132
 
117
- // A subsequent pause should still trigger a fresh recovery attempt.
133
+ // A subsequent pause should trigger a fresh, immediate recovery attempt.
134
+ setPaused(el, true);
118
135
  el.dispatchEvent(new Event('pause'));
119
136
  await vi.advanceTimersByTimeAsync(0);
120
137
  expect(play).toHaveBeenCalledTimes(3);
121
138
  });
122
139
 
140
+ it('backs off instead of retrying immediately when playback has not settled', async () => {
141
+ el.dispatchEvent(new Event('pause'));
142
+ await vi.advanceTimersByTimeAsync(0);
143
+ expect(play).toHaveBeenCalledTimes(1);
144
+
145
+ // resumed, but re-paused before the settle delay elapsed
146
+ setPaused(el, false);
147
+ el.dispatchEvent(new Event('playing'));
148
+ expect(tracer.trace).not.toHaveBeenCalledWith(
149
+ 'mediaPlayback.recover.success',
150
+ {
151
+ kind: 'audio',
152
+ attempts: 1,
153
+ },
154
+ );
155
+ await vi.advanceTimersByTimeAsync(500);
156
+ setPaused(el, true);
157
+ el.dispatchEvent(new Event('pause'));
158
+
159
+ // the next attempt is subject to backoff, not scheduled on the next tick
160
+ await vi.advanceTimersByTimeAsync(0);
161
+ expect(play).toHaveBeenCalledTimes(1);
162
+
163
+ await vi.advanceTimersByTimeAsync(6000);
164
+ expect(play).toHaveBeenCalledTimes(2);
165
+ });
166
+
167
+ it('stops fighting an element that re-pauses after every successful play', async () => {
168
+ setup({ kind: 'video' });
169
+ // Safari re-pauses the element right after play() resolves. This used to
170
+ // reset the attempt counter, so the loop spun with a zero delay forever.
171
+ play.mockImplementation(async () => {
172
+ setPaused(el, false);
173
+ el.dispatchEvent(new Event('playing'));
174
+ setPaused(el, true);
175
+ el.dispatchEvent(new Event('pause'));
176
+ });
177
+
178
+ el.dispatchEvent(new Event('pause'));
179
+ for (let i = 0; i < 20; i++) {
180
+ await vi.advanceTimersByTimeAsync(6000);
181
+ }
182
+
183
+ expect(play).toHaveBeenCalledTimes(10);
184
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.giveUp', {
185
+ kind: 'video',
186
+ attempts: 10,
187
+ });
188
+
189
+ // External playback can still emit `playing` after the watchdog gave up
190
+ // (for example from a user gesture or another playback helper). If it
191
+ // re-pauses before settling, it must not revive the watchdog or trace a
192
+ // false recovery success.
193
+ setPaused(el, false);
194
+ el.dispatchEvent(new Event('playing'));
195
+ setPaused(el, true);
196
+ el.dispatchEvent(new Event('pause'));
197
+ await vi.advanceTimersByTimeAsync(1000);
198
+
199
+ expect(tracer.trace).not.toHaveBeenCalledWith(
200
+ 'mediaPlayback.recover.success',
201
+ {
202
+ kind: 'video',
203
+ attempts: 10,
204
+ },
205
+ );
206
+
207
+ // and it stays stopped, without tracing the pause storm
208
+ const traceMock = tracer.trace as unknown as MockInstance;
209
+ const tracesBefore = traceMock.mock.calls.length;
210
+ for (let i = 0; i < 100; i++) {
211
+ el.dispatchEvent(new Event('pause'));
212
+ }
213
+ await vi.advanceTimersByTimeAsync(6000);
214
+
215
+ expect(play).toHaveBeenCalledTimes(10);
216
+ expect(traceMock.mock.calls.length).toBe(tracesBefore);
217
+ });
218
+
219
+ it('revives once playback genuinely holds for the settle window', async () => {
220
+ setup({ kind: 'video' });
221
+ play.mockImplementation(async () => {
222
+ setPaused(el, false);
223
+ el.dispatchEvent(new Event('playing'));
224
+ setPaused(el, true);
225
+ el.dispatchEvent(new Event('pause'));
226
+ });
227
+
228
+ el.dispatchEvent(new Event('pause'));
229
+ for (let i = 0; i < 20; i++) {
230
+ await vi.advanceTimersByTimeAsync(6000);
231
+ }
232
+ expect(play).toHaveBeenCalledTimes(10); // gave up
233
+
234
+ play.mockResolvedValue(undefined);
235
+ setPaused(el, false);
236
+ el.dispatchEvent(new Event('playing'));
237
+ await vi.advanceTimersByTimeAsync(1500);
238
+
239
+ expect(tracer.trace).toHaveBeenCalledWith('mediaPlayback.recover.success', {
240
+ kind: 'video',
241
+ attempts: 10,
242
+ });
243
+
244
+ // recovery is live again: the attempt counter was reset, so a fresh pause
245
+ // gets an immediate attempt rather than being ignored
246
+ setPaused(el, true);
247
+ el.dispatchEvent(new Event('pause'));
248
+ await vi.advanceTimersByTimeAsync(0);
249
+ expect(play).toHaveBeenCalledTimes(11);
250
+ });
251
+
123
252
  it('does not attempt recovery when srcObject is null', async () => {
124
253
  setup({ state: { srcObject: null } });
125
254
 
@@ -38,8 +38,7 @@ describe('ViewportTracker', () => {
38
38
 
39
39
  it('updates participant viewportVisibilityState as visibility changes', () => {
40
40
  let visibilityHandler:
41
- | ((entry: IntersectionObserverEntry) => void)
42
- | undefined;
41
+ ((entry: IntersectionObserverEntry) => void) | undefined;
43
42
  vi.spyOn(viewportTracker, 'observe').mockImplementation((_el, handler) => {
44
43
  visibilityHandler = handler;
45
44
  return vi.fn();
@@ -33,18 +33,12 @@ export type ClientEventStage =
33
33
  | 'FirstAudioFrame';
34
34
 
35
35
  export type MediaPermissionState =
36
- | 'INITIATED'
37
- | 'FAILED'
38
- | 'GRANTED'
39
- | 'NOT_INITIATED';
36
+ 'INITIATED' | 'FAILED' | 'GRANTED' | 'NOT_INITIATED';
40
37
 
41
38
  export type ReportedIceState = 'CONNECTED' | 'FAILED' | 'NOT_CONNECTED';
42
39
 
43
40
  export type JoinReason =
44
- | 'first-attempt'
45
- | 'network-available'
46
- | 'migration'
47
- | 'full-rejoin';
41
+ 'first-attempt' | 'network-available' | 'migration' | 'full-rejoin';
48
42
 
49
43
  export type ClientEventStandardCode =
50
44
  | 'CLIENT_ABORTED'
package/src/rtc/types.ts CHANGED
@@ -39,8 +39,7 @@ export const ReconnectReason = {
39
39
  } as const;
40
40
 
41
41
  export type ReconnectReason =
42
- | (typeof ReconnectReason)[keyof typeof ReconnectReason]
43
- | (string & {});
42
+ (typeof ReconnectReason)[keyof typeof ReconnectReason] | (string & {});
44
43
 
45
44
  export type OnReconnectionNeeded = (
46
45
  kind: WebsocketReconnectStrategy,
@@ -12,9 +12,15 @@ export type RTCStatsDataType =
12
12
  | boolean
13
13
  | RTCOfferOptions
14
14
  | [string | RTCDataChannelInit | undefined] // createDataChannel
15
- | (RTCOfferOptions | undefined) // createOffer | createAnswer
15
+ | (
16
+ | RTCOfferOptions
17
+ | undefined // createOffer | createAnswer
18
+ )
16
19
  | RTCSessionDescriptionInit
17
- | (RTCIceCandidateInit | RTCIceCandidate) // addIceCandidate
20
+ | (
21
+ | RTCIceCandidateInit
22
+ | RTCIceCandidate // addIceCandidate
23
+ )
18
24
  | object
19
25
  | number
20
26
  | null
@@ -341,8 +341,7 @@ export class CallState {
341
341
 
342
342
  private readonly eventHandlers: {
343
343
  [EventType in VideoEvent['type']]:
344
- | ((event: Extract<VideoEvent, { type: EventType }>) => void)
345
- | undefined;
344
+ ((event: Extract<VideoEvent, { type: EventType }>) => void) | undefined;
346
345
  };
347
346
 
348
347
  /**
package/src/types.ts CHANGED
@@ -143,10 +143,7 @@ export interface StreamVideoParticipant extends Participant {
143
143
  export type VideoTrackType = 'videoTrack' | 'screenShareTrack';
144
144
  export type AudioTrackType = 'audioTrack' | 'screenShareAudioTrack';
145
145
  export type TrackMuteType =
146
- | 'audio'
147
- | 'video'
148
- | 'screenshare'
149
- | 'screenshare_audio';
146
+ 'audio' | 'video' | 'screenshare' | 'screenshare_audio';
150
147
 
151
148
  /**
152
149
  * Represents a participant's pin state.