@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. package/src/types.ts +19 -0
@@ -7,7 +7,7 @@ import {
7
7
  retryInterval,
8
8
  sleep,
9
9
  } from './utils';
10
- import type { StreamVideoEvent, UR } from './types';
10
+ import type { StreamVideoEvent, UR, WSConnectionError } from './types';
11
11
  import type { LogLevel } from '@stream-io/logger';
12
12
  import type {
13
13
  ConnectedEvent,
@@ -36,59 +36,52 @@ import { APIErrorCodes } from './errors';
36
36
  * - if the servers fails to publish a message to the client, the WS connection is destroyed
37
37
  */
38
38
  export class StableWSConnection {
39
- // local vars
39
+ // Parent client reference.
40
+ client: StreamClient;
41
+
42
+ // Underlying WebSocket. wsID is bumped on each new connection so stale
43
+ // event handlers from previous sockets can be ignored.
44
+ ws?: WebSocket;
45
+ /** Incremented when a new WS connection is made */
46
+ wsID = 1;
47
+
48
+ // Connection lifecycle flags.
49
+ /** We only make 1 attempt to reconnect at the same time.. */
50
+ isConnecting = false;
51
+ /** To avoid reconnect if client is disconnected */
52
+ isDisconnected = false;
53
+ /** Boolean that indicates if we have a working connection to the server */
54
+ isHealthy = false;
55
+
56
+ // Open-connection promise: resolves on `connection.ok`, rejects on close/error.
40
57
  connectionID?: string;
41
58
  private connectionOpenSafe?: SafePromise<ConnectedEvent>;
42
- consecutiveFailures: number;
43
- pingInterval: number;
59
+ resolveConnectionOpen?: (value: ConnectedEvent) => void;
60
+ rejectConnectionOpen?: (reason?: WSConnectionError) => void;
61
+ /** Boolean that indicates if the connection promise is resolved */
62
+ isConnectionOpenResolved?: boolean = false;
63
+
64
+ // Failure counters (drive retry/backoff scheduling).
65
+ /** consecutive failures influence the duration of the timeout */
66
+ consecutiveFailures = 0;
67
+ /** keep track of the total number of failures */
68
+ totalFailures = 0;
69
+
70
+ // Health-check pings + connection-staleness check.
71
+ /** Send a health check message every 25 seconds */
72
+ pingInterval = 25 * 1000;
44
73
  healthCheckTimeoutRef?: number;
45
- isConnecting: boolean;
46
- isDisconnected: boolean;
47
- isHealthy: boolean;
48
- isConnectionOpenResolved?: boolean;
49
- lastEvent: Date | null;
50
- connectionCheckTimeout: number;
74
+ connectionCheckTimeout = this.pingInterval + 10 * 1000;
51
75
  connectionCheckTimeoutRef?: NodeJS.Timeout;
52
- rejectConnectionOpen?: (
53
- reason?: Error & {
54
- code?: string | number;
55
- isWSFailure?: boolean;
56
- StatusCode?: string | number;
57
- },
58
- ) => void;
59
- resolveConnectionOpen?: (value: ConnectedEvent) => void;
60
- totalFailures: number;
61
- ws?: WebSocket;
62
- wsID: number;
63
-
64
- client: StreamClient;
76
+ /** Store the last event time for health checks */
77
+ lastEvent: Date | null = null;
65
78
 
66
79
  constructor(client: StreamClient) {
67
80
  this.client = client;
68
- /** consecutive failures influence the duration of the timeout */
69
- this.consecutiveFailures = 0;
70
- /** keep track of the total number of failures */
71
- this.totalFailures = 0;
72
- /** We only make 1 attempt to reconnect at the same time.. */
73
- this.isConnecting = false;
74
- /** To avoid reconnect if client is disconnected */
75
- this.isDisconnected = false;
76
- /** Boolean that indicates if the connection promise is resolved */
77
- this.isConnectionOpenResolved = false;
78
- /** Boolean that indicates if we have a working connection to the server */
79
- this.isHealthy = false;
80
- /** Incremented when a new WS connection is made */
81
- this.wsID = 1;
82
- /** Store the last event time for health checks */
83
- this.lastEvent = null;
84
- /** Send a health check message every 25 seconds */
85
- this.pingInterval = 25 * 1000;
86
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
87
-
88
81
  addConnectionEventListeners(this.onlineStatusChanged);
89
82
  }
90
83
 
91
- _log = (msg: string, extra: UR = {}, level: LogLevel = 'info') => {
84
+ _log = (msg: string, extra: UR | Error = {}, level: LogLevel = 'info') => {
92
85
  this.client.logger[level](`connection:${msg}`, extra);
93
86
  };
94
87
 
@@ -99,9 +92,9 @@ export class StableWSConnection {
99
92
  /**
100
93
  * connect - Connect to the WS URL
101
94
  * the default 15s timeout allows between 2~3 tries
102
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
95
+ * @return Promise that completes once the first health check message is received
103
96
  */
104
- async connect(timeout = 15000) {
97
+ connect = async (timeout = 15000): Promise<ConnectedEvent | undefined> => {
105
98
  if (this.isConnecting) {
106
99
  throw Error(
107
100
  `You've called connect twice, can only attempt 1 connection at the time`,
@@ -111,18 +104,18 @@ export class StableWSConnection {
111
104
  this.isDisconnected = false;
112
105
 
113
106
  try {
114
- const healthCheck = await this._connect();
107
+ const healthCheck = await this._connect(timeout);
115
108
  this.consecutiveFailures = 0;
116
109
 
117
110
  this._log(
118
111
  `connect() - Established ws connection with healthcheck: ${healthCheck}`,
119
112
  );
120
- } catch (error) {
113
+ } catch (caught) {
114
+ const error = caught as WSConnectionError;
121
115
  this.isHealthy = false;
122
116
  this.consecutiveFailures += 1;
123
117
 
124
118
  if (
125
- // @ts-expect-error type issue
126
119
  error.code === KnownCodes.TOKEN_EXPIRED &&
127
120
  !this.client.tokenManager.isStatic()
128
121
  ) {
@@ -130,42 +123,44 @@ export class StableWSConnection {
130
123
  'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
131
124
  );
132
125
  this._reconnect({ refreshToken: true });
126
+ } else if (!error.isWSFailure) {
127
+ // API rejected the connection and we should not retry
128
+ throw new Error(
129
+ JSON.stringify({
130
+ code: error.code,
131
+ StatusCode: error.StatusCode,
132
+ message: error.message,
133
+ isWSFailure: error.isWSFailure,
134
+ }),
135
+ );
133
136
  } else {
134
- // @ts-expect-error type issue
135
- if (!error.isWSFailure) {
136
- // API rejected the connection and we should not retry
137
- throw new Error(
138
- JSON.stringify({
139
- // @ts-expect-error type issue
140
- code: error.code,
141
- // @ts-expect-error type issue
142
- StatusCode: error.StatusCode,
143
- // @ts-expect-error type issue
144
- message: error.message,
145
- // @ts-expect-error type issue
146
- isWSFailure: error.isWSFailure,
147
- }),
148
- );
149
- }
137
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
138
+ // reconnect chain so _waitForHealthy(timeout) below has something
139
+ // to poll for. Owning the trigger here (rather than inside
140
+ // _connect()'s catch) keeps a single failure from spawning two
141
+ // parallel chains - one from this catch and one from _reconnect's
142
+ // own catch when _connect was called from there.
143
+ this._reconnect();
150
144
  }
151
145
  }
152
146
 
153
147
  return await this._waitForHealthy(timeout);
154
- }
148
+ };
155
149
 
156
150
  /**
157
151
  * _waitForHealthy polls the promise connection to see if its resolved until it times out
158
152
  * the default 15s timeout allows between 2~3 tries
159
153
  * @param timeout duration(ms)
160
154
  */
161
- async _waitForHealthy(timeout = 15000) {
155
+ _waitForHealthy = async (timeout = 15000) => {
162
156
  return Promise.race([
163
157
  (async () => {
164
158
  const interval = 50; // ms
165
159
  for (let i = 0; i <= timeout; i += interval) {
166
160
  try {
167
161
  return await this.connectionOpen;
168
- } catch (error: any) {
162
+ } catch (caught) {
163
+ const error = caught as WSConnectionError;
169
164
  if (i === timeout) {
170
165
  throw new Error(
171
166
  JSON.stringify({
@@ -193,7 +188,7 @@ export class StableWSConnection {
193
188
  );
194
189
  })(),
195
190
  ]);
196
- }
191
+ };
197
192
 
198
193
  /**
199
194
  * Builds and returns the url for websocket.
@@ -211,9 +206,8 @@ export class StableWSConnection {
211
206
 
212
207
  /**
213
208
  * disconnect - Disconnect the connection and doesn't recover...
214
- *
215
209
  */
216
- disconnect(timeout?: number) {
210
+ disconnect = (timeout?: number) => {
217
211
  this._log(
218
212
  `disconnect() - Closing the websocket connection for wsID ${this.wsID}`,
219
213
  );
@@ -275,16 +269,30 @@ export class StableWSConnection {
275
269
  delete this.ws;
276
270
 
277
271
  return isClosedPromise;
278
- }
272
+ };
279
273
 
280
274
  /**
281
275
  * _connect - Connect to the WS endpoint
282
276
  *
283
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
277
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
278
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
279
+ * passes its own timeout through so caller-supplied deadlines are honored.
280
+ * @return Promise that completes once the first health check message is received
284
281
  */
285
- async _connect() {
282
+ _connect = async (
283
+ timeoutMs?: number,
284
+ ): Promise<ConnectedEvent | undefined> => {
286
285
  if (this.isConnecting) return; // ignore _connect if it's currently trying to connect
287
286
  this.isConnecting = true;
287
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
288
+ // Captured at function entry so that even early failures (e.g.,
289
+ // tokenManager.loadToken throwing before we reach the WS phase) can
290
+ // settle the promise the caller is awaiting. Re-captured below if
291
+ // _connect itself sets up a fresh promise. If a concurrent
292
+ // openConnection() rotates `client.rejectConnectionId` later, our
293
+ // captured closure still settles only the original promise (P1) and
294
+ // never poisons the newer one (P2).
295
+ let ownRejectConnectionId = this.client.rejectConnectionId;
288
296
  let isTokenReady = false;
289
297
  try {
290
298
  this._log(`_connect() - waiting for token`);
@@ -302,8 +310,11 @@ export class StableWSConnection {
302
310
  await this.client.tokenManager.loadToken();
303
311
  }
304
312
 
305
- if (!this.client.isConnectionIsPromisePending) {
313
+ if (!this.client.isConnectionIdPromisePending) {
306
314
  this.client._setupConnectionIdPromise();
315
+ // recapture: we just rotated the resolver ourselves, the new
316
+ // closure is the one bound to the promise this attempt owns.
317
+ ownRejectConnectionId = this.client.rejectConnectionId;
307
318
  }
308
319
  this._setupConnectionPromise();
309
320
  const wsURL = this._buildUrl();
@@ -314,23 +325,74 @@ export class StableWSConnection {
314
325
  this.ws.onclose = this.onclose.bind(this, this.wsID);
315
326
  this.ws.onerror = this.onerror.bind(this, this.wsID);
316
327
  this.ws.onmessage = this.onmessage.bind(this, this.wsID);
317
- const response = await this.connectionOpen;
328
+
329
+ // race the WS handshake against an explicit deadline so a silent
330
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
331
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
332
+ const timers = getTimers();
333
+ let handshakeTimeoutId: number | undefined;
334
+ let response: ConnectedEvent | undefined;
335
+ try {
336
+ response = await Promise.race<ConnectedEvent | undefined>([
337
+ this.connectionOpen as Promise<ConnectedEvent>,
338
+ new Promise<never>((_, reject) => {
339
+ handshakeTimeoutId = timers.setTimeout(() => {
340
+ const err: WSConnectionError = new Error(
341
+ `WS handshake timed out after ${handshakeTimeout}ms`,
342
+ );
343
+ err.isWSFailure = true;
344
+ reject(err);
345
+ }, handshakeTimeout);
346
+ }),
347
+ ]);
348
+ } finally {
349
+ timers.clearTimeout(handshakeTimeoutId);
350
+ }
318
351
  this.isConnecting = false;
319
352
 
353
+ // If we were disconnected during the handshake (e.g. closeConnection()
354
+ // ran while a background _reconnect's _connect was in flight), tear
355
+ // down the new WS and throw so the caller of connect() does not get
356
+ // a misleading "success" for a connection that has already been
357
+ // aborted. We must NOT skip the throw and just return undefined: the
358
+ // outer connect() would otherwise fall through to _waitForHealthy(),
359
+ // which would observe the already-resolved connectionOpen promise
360
+ // and resolve with a ConnectedEvent for a torn-down connection.
361
+ if (this.isDisconnected) {
362
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
363
+ this._destroyCurrentWSConnection();
364
+ }
365
+ throw new Error(
366
+ 'WS handshake aborted: disconnect() ran while connecting',
367
+ );
368
+ }
369
+
320
370
  if (response) {
321
371
  this.connectionID = response.connection_id;
322
372
  this.client.resolveConnectionId?.(this.connectionID);
323
373
  return response;
324
374
  }
325
- } catch (err) {
326
- this.client._setupConnectionIdPromise();
375
+ } catch (caught) {
376
+ const err = caught as WSConnectionError;
327
377
  this.isConnecting = false;
328
- // @ts-expect-error type issue
329
378
  this._log(`_connect() - Error - `, err);
330
- this.client.rejectConnectionId?.(err);
379
+ // Reject THIS attempt's connection-id promise (P1) directly via the
380
+ // captured closure. Whether or not a concurrent openConnection() has
381
+ // since rotated client.rejectConnectionId to a newer promise (P2),
382
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
383
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
384
+ // therefore fail fast instead of being orphaned.
385
+ ownRejectConnectionId?.(err);
386
+ // connectionOpen is per-instance and not subject to rotation, so
387
+ // calling it unconditionally is safe (and a no-op if already settled).
388
+ this.rejectConnectionOpen?.(err);
389
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
390
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
391
+ this._destroyCurrentWSConnection();
392
+ }
331
393
  throw err;
332
394
  }
333
- }
395
+ };
334
396
 
335
397
  /**
336
398
  * _reconnect - Retry the connection to WS endpoint
@@ -388,7 +450,8 @@ export class StableWSConnection {
388
450
  this._log('_reconnect() - Finished recoverCallBack');
389
451
 
390
452
  this.consecutiveFailures = 0;
391
- } catch (error: any) {
453
+ } catch (caught) {
454
+ const error = caught as WSConnectionError;
392
455
  this.isHealthy = false;
393
456
  this.consecutiveFailures += 1;
394
457
  if (
@@ -416,7 +479,6 @@ export class StableWSConnection {
416
479
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
417
480
  *
418
481
  * @param {Event} event Event with type online or offline
419
- *
420
482
  */
421
483
  onlineStatusChanged = (event: Event) => {
422
484
  if (event.type === 'offline') {
@@ -538,19 +600,14 @@ export class StableWSConnection {
538
600
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
539
601
 
540
602
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
541
- // this is a permanent error raised by stream..
603
+ // this is a permanent error raised by stream.
542
604
  // usually caused by invalid auth details
543
- const error = new Error(
605
+ const error: WSConnectionError = new Error(
544
606
  `WS connection reject with error ${event.reason}`,
545
607
  );
546
-
547
- // @ts-expect-error type issue
548
608
  error.reason = event.reason;
549
- // @ts-expect-error type issue
550
609
  error.code = event.code;
551
- // @ts-expect-error type issue
552
610
  error.wasClean = event.wasClean;
553
- // @ts-expect-error type issue
554
611
  error.target = event.target;
555
612
 
556
613
  this.rejectConnectionOpen?.(error);
@@ -622,7 +679,7 @@ export class StableWSConnection {
622
679
  private _errorFromWSEvent = (
623
680
  event: CloseEvent | ConnectionErrorEvent,
624
681
  isWSFailure = true,
625
- ) => {
682
+ ): WSConnectionError => {
626
683
  let code: number;
627
684
  let statusCode: number;
628
685
  let message: string;
@@ -639,11 +696,7 @@ export class StableWSConnection {
639
696
 
640
697
  const msg = `WS failed with code: ${code}: ${APIErrorCodes[code] || code} and reason: ${message}`;
641
698
  this._log(msg, { event }, 'warn');
642
- const error = new Error(msg) as Error & {
643
- code?: number;
644
- isWSFailure?: boolean;
645
- StatusCode?: number;
646
- };
699
+ const error = new Error(msg) as WSConnectionError;
647
700
  error.code = code;
648
701
  /**
649
702
  * StatusCode does not exist on any event types but has been left
@@ -42,6 +42,21 @@ export type APIErrorResponse = {
42
42
  unrecoverable?: boolean;
43
43
  };
44
44
 
45
+ /**
46
+ * A standard `Error` augmented with the metadata that the coordinator
47
+ * WebSocket connection layer attaches to rejection causes.
48
+ * `isWSFailure: true` marks transient/retriable failures; absent or `false`
49
+ * indicates a permanent failure that should not be retried.
50
+ */
51
+ export type WSConnectionError = Error & {
52
+ code?: string | number;
53
+ isWSFailure?: boolean;
54
+ StatusCode?: string | number;
55
+ reason?: string;
56
+ wasClean?: boolean;
57
+ target?: EventTarget | null;
58
+ };
59
+
45
60
  export class ErrorFromResponse<T> extends Error {
46
61
  public code: number | null;
47
62
  public status: number;
@@ -4,6 +4,21 @@ import type { ConnectionErrorEvent } from '../../gen/coordinator';
4
4
 
5
5
  export const sleep = (m: number) => new Promise((r) => setTimeout(r, m));
6
6
 
7
+ export const timeboxed = async <T extends Promise<unknown>[]>(
8
+ promises: [...T],
9
+ ms: number,
10
+ ): Promise<{ [K in keyof T]: Awaited<T[K]> }> => {
11
+ let timerId: ReturnType<typeof setTimeout>;
12
+ const timeout = new Promise<never>((_, reject) => {
13
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
14
+ });
15
+ try {
16
+ return await Promise.race([Promise.all(promises), timeout]);
17
+ } finally {
18
+ clearTimeout(timerId!);
19
+ }
20
+ };
21
+
7
22
  export function isFunction<T>(value: Function | T): value is Function {
8
23
  return (
9
24
  value &&
@@ -9,6 +9,7 @@ import {
9
9
  type InputDeviceStatus,
10
10
  } from './DeviceManagerState';
11
11
  import { isMobile } from '../helpers/compatibility';
12
+ import { isWebKit } from '../helpers/browsers';
12
13
  import { isReactNative } from '../helpers/platforms';
13
14
  import { ScopedLogger, videoLoggerSystem } from '../logger';
14
15
  import { TrackType } from '../gen/video/sfu/models/models';
@@ -24,6 +25,7 @@ import {
24
25
  MediaStreamFilterEntry,
25
26
  MediaStreamFilterRegistrationResult,
26
27
  } from './filters';
28
+ import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
27
29
  import {
28
30
  createSyntheticDevice,
29
31
  defaultDeviceId,
@@ -49,6 +51,7 @@ export abstract class DeviceManager<
49
51
  protected readonly call: Call;
50
52
  protected readonly trackType: TrackType;
51
53
  protected subscriptions: (() => void)[] = [];
54
+ protected currentStreamCleanups: (() => void)[] = [];
52
55
  protected devicePersistence: Required<DevicePersistenceOptions>;
53
56
  protected areSubscriptionsSetUp = false;
54
57
  private isTrackStoppedDueToTrackEnd = false;
@@ -292,11 +295,17 @@ export abstract class DeviceManager<
292
295
  * @internal
293
296
  */
294
297
  dispose = () => {
298
+ this.runCurrentStreamCleanups();
295
299
  this.subscriptions.forEach((s) => s());
296
300
  this.subscriptions = [];
297
301
  this.areSubscriptionsSetUp = false;
298
302
  };
299
303
 
304
+ private runCurrentStreamCleanups = () => {
305
+ this.currentStreamCleanups.forEach((c) => c());
306
+ this.currentStreamCleanups = [];
307
+ };
308
+
300
309
  protected async applySettingsToStream() {
301
310
  await withCancellation(this.statusChangeConcurrencyTag, async (signal) => {
302
311
  if (this.enabled) {
@@ -353,7 +362,9 @@ export abstract class DeviceManager<
353
362
  // @ts-expect-error called to dispose the stream in RN
354
363
  mediaStream.release();
355
364
  }
365
+ this.runCurrentStreamCleanups();
356
366
  this.state.setMediaStream(undefined, undefined);
367
+ this.setLocalInterrupted(false);
357
368
  this.filters.forEach((entry) => entry.stop?.());
358
369
  }
359
370
  }
@@ -390,7 +401,7 @@ export abstract class DeviceManager<
390
401
  protected async unmuteStream() {
391
402
  this.logger.debug('Starting stream');
392
403
  let stream: MediaStream;
393
- let rootStream: Promise<MediaStream> | undefined;
404
+ let rootStreamPromise: Promise<MediaStream> | undefined;
394
405
  if (
395
406
  this.state.mediaStream &&
396
407
  this.getTracks().every((t) => t.readyState === 'live')
@@ -398,6 +409,11 @@ export abstract class DeviceManager<
398
409
  stream = this.state.mediaStream;
399
410
  this.enableTracks();
400
411
  } else {
412
+ // We are about to compose a fresh filter chain and acquire a new
413
+ // root stream. Drop any listeners bound to the previous root stream
414
+ // before chainWith below registers new ones for the new chain.
415
+ this.runCurrentStreamCleanups();
416
+
401
417
  const defaultConstraints = this.state.defaultConstraints;
402
418
  const constraints: MediaTrackConstraints = {
403
419
  ...defaultConstraints,
@@ -455,7 +471,7 @@ export abstract class DeviceManager<
455
471
  });
456
472
  };
457
473
  parentTrack.addEventListener('ended', handleParentTrackEnded);
458
- this.subscriptions.push(() => {
474
+ this.currentStreamCleanups.push(() => {
459
475
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
460
476
  });
461
477
  });
@@ -465,7 +481,7 @@ export abstract class DeviceManager<
465
481
 
466
482
  // the rootStream represents the stream coming from the actual device
467
483
  // e.g. camera or microphone stream
468
- rootStream = this.getStream(constraints as C);
484
+ rootStreamPromise = this.getStream(constraints as C);
469
485
  // we publish the last MediaStream of the chain
470
486
  stream = await this.filters.reduce(
471
487
  (parent, entry) =>
@@ -482,45 +498,89 @@ export abstract class DeviceManager<
482
498
  );
483
499
  return parent;
484
500
  }),
485
- rootStream,
501
+ rootStreamPromise,
486
502
  );
487
503
  }
488
504
  if (this.call.state.callingState === CallingState.JOINED) {
489
505
  await this.publishStream(stream);
490
506
  }
491
507
  if (this.state.mediaStream !== stream) {
492
- this.state.setMediaStream(stream, await rootStream);
493
- const handleTrackEnded = async () => {
494
- await this.statusChangeSettled();
495
- if (this.enabled) {
496
- this.isTrackStoppedDueToTrackEnd = true;
497
- setTimeout(() => {
498
- this.isTrackStoppedDueToTrackEnd = false;
499
- }, 2000);
500
- await this.disable();
501
- }
502
- };
503
- const createTrackMuteHandler = (muted: boolean) => () => {
504
- if (!isMobile() || this.trackType !== TrackType.VIDEO) return;
505
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
506
- this.logger.warn('Error while notifying track mute state', err);
507
- });
508
- };
509
- stream.getTracks().forEach((track) => {
510
- const muteHandler = createTrackMuteHandler(true);
511
- const unmuteHandler = createTrackMuteHandler(false);
512
- track.addEventListener('mute', muteHandler);
513
- track.addEventListener('unmute', unmuteHandler);
514
- track.addEventListener('ended', handleTrackEnded);
515
- this.subscriptions.push(() => {
516
- track.removeEventListener('mute', muteHandler);
517
- track.removeEventListener('unmute', unmuteHandler);
518
- track.removeEventListener('ended', handleTrackEnded);
508
+ const rootStream = await rootStreamPromise;
509
+ this.state.setMediaStream(stream, rootStream);
510
+ if (rootStream) {
511
+ const handleTrackEnded = async () => {
512
+ this.setLocalInterrupted(false);
513
+ await this.statusChangeSettled();
514
+ if (this.enabled) {
515
+ this.isTrackStoppedDueToTrackEnd = true;
516
+ setTimeout(() => {
517
+ this.isTrackStoppedDueToTrackEnd = false;
518
+ }, 2000);
519
+ await this.disable();
520
+ }
521
+ };
522
+ const createTrackMuteHandler = (muted: boolean) => () => {
523
+ this.setLocalInterrupted(muted);
524
+
525
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
526
+ // macOS audio session interruption even though the track is
527
+ // unmuted. Re-arm the sender on every unmute for any WebKit
528
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
529
+ // page is hidden because the encoder won't resume until
530
+ // foreground anyway.
531
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
532
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
533
+ this.logger.warn('Failed to refresh track on system unmute', err);
534
+ });
535
+ }
536
+
537
+ // report all tracks on mobile, and only Video on desktop browsers
538
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
539
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
540
+ trackType: TrackType[this.trackType],
541
+ muted,
542
+ });
543
+ this.call
544
+ .notifyTrackMuteState(muted, this.trackType)
545
+ .catch((err) => {
546
+ this.logger.warn('Error while notifying track mute state', err);
547
+ });
548
+ }
549
+ };
550
+ rootStream.getTracks().forEach((track) => {
551
+ const muteHandler = createTrackMuteHandler(true);
552
+ const unmuteHandler = createTrackMuteHandler(false);
553
+ track.addEventListener('mute', muteHandler);
554
+ track.addEventListener('unmute', unmuteHandler);
555
+ track.addEventListener('ended', handleTrackEnded);
556
+ this.currentStreamCleanups.push(() => {
557
+ track.removeEventListener('mute', muteHandler);
558
+ track.removeEventListener('unmute', unmuteHandler);
559
+ track.removeEventListener('ended', handleTrackEnded);
560
+ });
519
561
  });
520
- });
562
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
563
+ this.setLocalInterrupted(initialMuted);
564
+ } else {
565
+ this.setLocalInterrupted(false);
566
+ }
521
567
  }
522
568
  }
523
569
 
570
+ private setLocalInterrupted = (interrupted: boolean) => {
571
+ const localParticipant = this.call.state.localParticipant;
572
+ if (!localParticipant) return;
573
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
574
+ const current = p.interruptedTracks ?? [];
575
+ const has = current.includes(this.trackType);
576
+ if (interrupted === has) return {};
577
+ const next = interrupted
578
+ ? pushToIfMissing([...current], this.trackType)
579
+ : removeFromIfPresent([...current], this.trackType);
580
+ return { interruptedTracks: next };
581
+ });
582
+ };
583
+
524
584
  private get mediaDeviceKind(): MediaDeviceKind {
525
585
  if (this.trackType === TrackType.AUDIO) return 'audioinput';
526
586
  if (this.trackType === TrackType.VIDEO) return 'videoinput';
@@ -36,7 +36,6 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
36
36
 
37
37
  /**
38
38
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
39
- *
40
39
  */
41
40
  mediaStream$ = this.mediaStreamSubject.asObservable();
42
41