@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
@@ -1,36 +1,30 @@
1
+ import type { CallState } from '../store';
2
+ import { VideoTrackType } from '../types';
1
3
  export type EntryHandler = (entry: IntersectionObserverEntry) => void;
2
4
  export type Unobserve = () => void;
3
5
  export type Observe = (element: HTMLElement, entryHandler: EntryHandler) => Unobserve;
4
6
  export declare class ViewportTracker {
5
- /**
6
- * @private
7
- */
7
+ private callState;
8
8
  private elementHandlerMap;
9
- /**
10
- * @private
11
- */
12
9
  private observer;
13
- /**
14
- * @private
15
- */
16
10
  private queueSet;
11
+ constructor(callState: CallState);
17
12
  /**
18
13
  * Method to set scrollable viewport as root for the IntersectionObserver, returns
19
14
  * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
20
- *
21
- * @param viewportElement
22
- * @param options
23
- * @returns Unobserve
24
15
  */
25
16
  setViewport: (viewportElement: HTMLElement, options?: Pick<IntersectionObserverInit, "threshold" | "rootMargin">) => () => void;
26
17
  /**
27
18
  * Method to set element to observe and handler to be triggered whenever IntersectionObserver
28
19
  * detects a possible change in element's visibility within specified viewport, returns
29
20
  * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
30
- *
31
- * @param element
32
- * @param handler
33
- * @returns Unobserve
34
21
  */
35
22
  observe: Observe;
23
+ /**
24
+ * Tracks the given element for visibility changes and mirrors the result
25
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
26
+ * Returns a function that unobserves the element and resets the visibility
27
+ * state back to `UNKNOWN`.
28
+ */
29
+ trackElementVisibility: <T extends HTMLElement>(element: T, sessionId: string, trackType: VideoTrackType) => () => void;
36
30
  }
@@ -2,6 +2,19 @@
2
2
  * Checks whether the current browser is Safari.
3
3
  */
4
4
  export declare const isSafari: () => boolean;
5
+ /**
6
+ * Checks whether the current runtime is a WebKit-engine browser.
7
+ *
8
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
9
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
10
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
11
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
12
+ * share the underlying WebKit quirks.
13
+ *
14
+ * Returns false for desktop Chromium-based browsers (which reuse the
15
+ * `AppleWebKit/` token in their UA) and Android.
16
+ */
17
+ export declare const isWebKit: () => boolean;
5
18
  /**
6
19
  * Checks whether the current browser is Firefox.
7
20
  */
@@ -1,3 +1,4 @@
1
+ type TagKey = string | symbol | object;
1
2
  /**
2
3
  * Runs async functions serially. Useful for wrapping async actions that
3
4
  * should never run simultaneously: if marked with the same tag, functions
@@ -8,7 +9,7 @@
8
9
  * @param cb Async function to run.
9
10
  * @returns Promise that resolves when async functions returns.
10
11
  */
11
- export declare const withoutConcurrency: <T>(tag: string | symbol, cb: () => Promise<T>) => Promise<T>;
12
+ export declare const withoutConcurrency: <T>(tag: TagKey, cb: () => Promise<T>) => Promise<T>;
12
13
  /**
13
14
  * Runs async functions serially, and cancels all other actions with the same tag
14
15
  * when a new action is scheduled. Useful for wrapping async actions that override
@@ -25,6 +26,7 @@ export declare const withoutConcurrency: <T>(tag: string | symbol, cb: () => Pro
25
26
  * start and was canceled, will resolve with 'canceled'. If the function started to run,
26
27
  * it's up to the function to decide how to react to cancelation.
27
28
  */
28
- export declare const withCancellation: <T>(tag: string | symbol, cb: (signal: AbortSignal) => Promise<T | "canceled">) => Promise<T | "canceled">;
29
- export declare function hasPending(tag: string | symbol): boolean;
30
- export declare function settled(tag: string | symbol): Promise<void>;
29
+ export declare const withCancellation: <T>(tag: TagKey, cb: (signal: AbortSignal) => Promise<T | "canceled">) => Promise<T | "canceled">;
30
+ export declare function hasPending(tag: TagKey): boolean;
31
+ export declare function settled(tag: TagKey): Promise<void>;
32
+ export {};
@@ -51,6 +51,23 @@ export declare class Publisher extends BasePeerConnection {
51
51
  * @param trackType the track type to check. If omitted, checks if any track is being published.
52
52
  */
53
53
  isPublishing: (trackType?: TrackType) => boolean;
54
+ /**
55
+ * Re-arms the encoder for the given track type by detaching and
56
+ * reattaching the currently published track on each matching sender.
57
+ *
58
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
59
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
60
+ * can stop producing RTP packets even though the underlying
61
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
62
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
63
+ * sender's encoder pipeline without renegotiation, restoring packet
64
+ * flow with the same SSRC.
65
+ *
66
+ * No-op when nothing is published for the given track type.
67
+ *
68
+ * @param trackType the track type to refresh.
69
+ */
70
+ refreshTrack: (trackType: TrackType) => Promise<void>;
54
71
  /**
55
72
  * Stops the cloned track that is being published to the SFU.
56
73
  */
@@ -22,5 +22,6 @@ export declare class Subscriber extends BasePeerConnection {
22
22
  */
23
23
  restartIce: () => Promise<void>;
24
24
  private handleOnTrack;
25
+ private setRemoteTrackInterrupted;
25
26
  private negotiate;
26
27
  }
@@ -0,0 +1,2 @@
1
+ import { DegradationPreference } from '../../gen/video/sfu/models/models';
2
+ export declare const toRTCDegradationPreference: (preference: DegradationPreference) => RTCDegradationPreference | undefined;
@@ -1,5 +1,5 @@
1
1
  import type { PerformanceStats } from '../../gen/video/sfu/models/models';
2
- export type RTCStatsDataType = RTCConfiguration | RTCIceCandidate | RTCSignalingState | RTCIceConnectionState | RTCIceGatheringState | RTCPeerConnectionState | [number | null | string] | string | boolean | RTCOfferOptions | [string | RTCDataChannelInit | undefined] | (RTCOfferOptions | undefined) | RTCSessionDescriptionInit | (RTCIceCandidateInit | RTCIceCandidate) | object | null | undefined;
2
+ export type RTCStatsDataType = RTCConfiguration | RTCIceCandidate | RTCSignalingState | RTCIceConnectionState | RTCIceGatheringState | RTCPeerConnectionState | [number | null | string] | string | boolean | RTCOfferOptions | [string | RTCDataChannelInit | undefined] | (RTCOfferOptions | undefined) | RTCSessionDescriptionInit | (RTCIceCandidateInit | RTCIceCandidate) | object | number | null | undefined;
3
3
  export type TraceKey = 'device-enumeration' | (string & {});
4
4
  export type Trace = (tag: string, data: RTCStatsDataType) => void;
5
5
  export type TraceRecord = [
@@ -1,5 +1,6 @@
1
1
  import { BehaviorSubject, Observable, Subject } from 'rxjs';
2
2
  type FunctionPatch<T> = (currentValue: T) => T;
3
+ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
3
4
  /**
4
5
  * A value or a function which takes the current value and returns a new value.
5
6
  */
@@ -21,6 +22,14 @@ export declare const getCurrentValue: <T>(observable$: Observable<T>) => T;
21
22
  * @return the updated value.
22
23
  */
23
24
  export declare const setCurrentValue: <T>(subject: Subject<T>, update: Patch<T>) => T;
25
+ /**
26
+ * Updates the value of the provided Subject asynchronously.
27
+ * Locks the subject to prevent concurrent updates.
28
+ *
29
+ * @param subject the subject to update.
30
+ * @param update the update to apply to the subject.
31
+ */
32
+ export declare const setCurrentValueAsync: <T>(subject: Subject<T>, update: AsyncFunctionPatch<T>) => Promise<T>;
24
33
  /**
25
34
  * Updates the value of the provided Subject and returns the previous value
26
35
  * and a function to roll back the update.
@@ -58,6 +58,24 @@ export interface StreamVideoParticipant extends Participant {
58
58
  * This is useful to avoid any unwanted video and audio artifacts.
59
59
  */
60
60
  pausedTracks?: TrackType[];
61
+ /**
62
+ * The list of tracks that are currently not producing media.
63
+ *
64
+ * For remote participants this is currently surfaced for `TrackType.AUDIO`
65
+ * only and reflects the receiver-side `RTCRtpReceiver` track `mute`/`unmute`
66
+ * state, so it covers system mute on the sender (OS audio session
67
+ * interruption, etc.), the sender pausing its track, sustained RTP stalls,
68
+ * and SFU drops. Remote video and screen-share interruption is not tracked.
69
+ *
70
+ * For the local participant it reflects the local track `mute`/`unmute`
71
+ * events surfaced by the browser (e.g. bluetooth disconnect, OS-level
72
+ * mic/camera kill switch, iOS audio session interruption).
73
+ *
74
+ * Orthogonal to `publishedTracks`: a track can be in `publishedTracks`
75
+ * AND in `interruptedTracks` (the participant intends to publish, but
76
+ * no media is flowing right now).
77
+ */
78
+ interruptedTracks?: TrackType[];
61
79
  /**
62
80
  * True if the participant is the local participant.
63
81
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.49.0",
3
+ "version": "1.50.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -46,7 +46,7 @@
46
46
  "@openapitools/openapi-generator-cli": "^2.25.0",
47
47
  "@rollup/plugin-replace": "^6.0.2",
48
48
  "@rollup/plugin-typescript": "^12.1.4",
49
- "@stream-io/audio-filters-web": "^0.7.3",
49
+ "@stream-io/audio-filters-web": "^0.8.0",
50
50
  "@stream-io/node-sdk": "^0.7.28",
51
51
  "@total-typescript/shoehorn": "^0.1.2",
52
52
  "@types/sdp-transform": "^2.15.0",
package/src/Call.ts CHANGED
@@ -145,7 +145,11 @@ import {
145
145
  StatsReporter,
146
146
  Tracer,
147
147
  } from './stats';
148
+ import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
149
+ import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
150
+ import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
148
151
  import { DynascaleManager } from './helpers/DynascaleManager';
152
+ import { ViewportTracker } from './helpers/ViewportTracker';
149
153
  import { PermissionsContext } from './permissions';
150
154
  import { CallTypes } from './CallType';
151
155
  import { StreamClient } from './coordinator/connection/client';
@@ -230,7 +234,32 @@ export class Call {
230
234
  /**
231
235
  * The DynascaleManager instance.
232
236
  */
233
- readonly dynascaleManager: DynascaleManager;
237
+ readonly dynascaleManager: DynascaleManager | undefined;
238
+
239
+ /**
240
+ * Tracks viewport visibility for participant video elements.
241
+ * Available only in DOM environments.
242
+ */
243
+ readonly viewportTracker: ViewportTracker | undefined;
244
+
245
+ /**
246
+ * Owns the SFU-side video-subscription state (per-session and global overrides).
247
+ */
248
+ readonly trackSubscriptionManager: TrackSubscriptionManager;
249
+
250
+ /**
251
+ * Warns periodically when a remote participant is publishing audio, but no
252
+ * `<audio>` element has been bound for them.
253
+ */
254
+ readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
255
+
256
+ /**
257
+ * Tracks audio elements blocked by the browser's autoplay policy.
258
+ * Subscribe to `blockedAudioTracker.autoplayBlocked$` to react to the
259
+ * blocked state, and call {@link Call.resumeAudio} inside a user gesture
260
+ * to retry playback.
261
+ */
262
+ readonly blockedAudioTracker: BlockedAudioTracker;
234
263
 
235
264
  subscriber?: Subscriber;
236
265
  publisher?: Publisher;
@@ -362,11 +391,26 @@ export class Call {
362
391
  this.microphone = new MicrophoneManager(this, preferences);
363
392
  this.speaker = new SpeakerManager(this, preferences);
364
393
  this.screenShare = new ScreenShareManager(this);
365
- this.dynascaleManager = new DynascaleManager(
394
+ this.trackSubscriptionManager = new TrackSubscriptionManager(
366
395
  this.state,
367
- this.speaker,
368
396
  this.tracer,
369
397
  );
398
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
399
+
400
+ if (typeof document !== 'undefined') {
401
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(
402
+ this.state,
403
+ this.tracer,
404
+ );
405
+ this.viewportTracker = new ViewportTracker(this.state);
406
+ this.dynascaleManager = new DynascaleManager(
407
+ this.state,
408
+ this.speaker,
409
+ this.tracer,
410
+ this.trackSubscriptionManager,
411
+ this.blockedAudioTracker,
412
+ );
413
+ }
370
414
  }
371
415
 
372
416
  /**
@@ -701,8 +745,10 @@ export class Call {
701
745
 
702
746
  await this.sfuClient?.leaveAndClose(leaveReason);
703
747
  this.sfuClient = undefined;
704
- this.dynascaleManager.setSfuClient(undefined);
705
- await this.dynascaleManager.dispose();
748
+ this.trackSubscriptionManager.setSfuClient(undefined);
749
+ this.trackSubscriptionManager.dispose();
750
+ this.audioBindingsWatchdog?.dispose();
751
+ await this.dynascaleManager?.dispose();
706
752
 
707
753
  this.state.setCallingState(CallingState.LEFT);
708
754
  this.state.setParticipants([]);
@@ -1126,7 +1172,7 @@ export class Call {
1126
1172
  : previousSfuClient;
1127
1173
  this.sfuClient = sfuClient;
1128
1174
  this.unifiedSessionId ??= sfuClient.sessionId;
1129
- this.dynascaleManager.setSfuClient(sfuClient);
1175
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
1130
1176
 
1131
1177
  const clientDetails = await getClientDetails();
1132
1178
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
@@ -1293,7 +1339,7 @@ export class Call {
1293
1339
  return {
1294
1340
  strategy,
1295
1341
  announcedTracks,
1296
- subscriptions: this.dynascaleManager.trackSubscriptions,
1342
+ subscriptions: this.trackSubscriptionManager.subscriptions,
1297
1343
  reconnectAttempt: this.reconnectAttempts,
1298
1344
  fromSfuId: migratingFromSfuId || '',
1299
1345
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -1976,7 +2022,7 @@ export class Call {
1976
2022
  private restoreSubscribedTracks = () => {
1977
2023
  const { remoteParticipants } = this.state;
1978
2024
  if (remoteParticipants.length <= 0) return;
1979
- this.dynascaleManager.applyTrackSubscriptions(undefined);
2025
+ this.trackSubscriptionManager.apply(undefined);
1980
2026
  };
1981
2027
 
1982
2028
  /**
@@ -2097,6 +2143,20 @@ export class Call {
2097
2143
  }
2098
2144
  };
2099
2145
 
2146
+ /**
2147
+ * Re-arms the encoder for a currently published track type. Useful for
2148
+ * working around WebKit's stalled sender bug after an iOS audio session
2149
+ * interruption (Siri, PSTN call).
2150
+ *
2151
+ * @internal
2152
+ *
2153
+ * @param trackType the track type to refresh.
2154
+ */
2155
+ refreshPublishedTrack = async (trackType: TrackType) => {
2156
+ if (!this.publisher) return;
2157
+ await this.publisher.refreshTrack(trackType);
2158
+ };
2159
+
2100
2160
  /**
2101
2161
  * Updates the preferred publishing options
2102
2162
  *
@@ -3010,7 +3070,7 @@ export class Call {
3010
3070
  sessionId: string,
3011
3071
  trackType: VideoTrackType,
3012
3072
  ) => {
3013
- return this.dynascaleManager.trackElementVisibility(
3073
+ return this.viewportTracker?.trackElementVisibility(
3014
3074
  element,
3015
3075
  sessionId,
3016
3076
  trackType,
@@ -3023,7 +3083,7 @@ export class Call {
3023
3083
  * @param element the viewport element.
3024
3084
  */
3025
3085
  setViewport = <T extends HTMLElement>(element: T) => {
3026
- return this.dynascaleManager.setViewport(element);
3086
+ return this.viewportTracker?.setViewport(element);
3027
3087
  };
3028
3088
 
3029
3089
  /**
@@ -3046,7 +3106,7 @@ export class Call {
3046
3106
  sessionId: string,
3047
3107
  trackType: VideoTrackType,
3048
3108
  ) => {
3049
- const unbind = this.dynascaleManager.bindVideoElement(
3109
+ const unbind = this.dynascaleManager?.bindVideoElement(
3050
3110
  videoElement,
3051
3111
  sessionId,
3052
3112
  trackType,
@@ -3075,26 +3135,33 @@ export class Call {
3075
3135
  sessionId: string,
3076
3136
  trackType: AudioTrackType = 'audioTrack',
3077
3137
  ) => {
3078
- const unbind = this.dynascaleManager.bindAudioElement(
3138
+ const unbind = this.dynascaleManager?.bindAudioElement(
3079
3139
  audioElement,
3080
3140
  sessionId,
3081
3141
  trackType,
3082
3142
  );
3083
3143
 
3084
3144
  if (!unbind) return;
3085
- this.leaveCallHooks.add(unbind);
3086
- return () => {
3087
- this.leaveCallHooks.delete(unbind);
3145
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
3146
+ const cleanup = () => {
3088
3147
  unbind();
3148
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
3149
+ };
3150
+ this.leaveCallHooks.add(cleanup);
3151
+ return () => {
3152
+ this.leaveCallHooks.delete(cleanup);
3153
+ cleanup();
3089
3154
  };
3090
3155
  };
3091
3156
 
3092
3157
  /**
3093
3158
  * Plays all audio elements blocked by the browser's autoplay policy.
3159
+ * Must be called from within a user gesture (e.g., click handler).
3160
+ *
3161
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
3162
+ * gesture is required.
3094
3163
  */
3095
- resumeAudio = () => {
3096
- return this.dynascaleManager.resumeAudio();
3097
- };
3164
+ resumeAudio = () => this.blockedAudioTracker.resumeAudio();
3098
3165
 
3099
3166
  /**
3100
3167
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
@@ -3148,7 +3215,7 @@ export class Call {
3148
3215
  resolution: VideoDimension | undefined,
3149
3216
  sessionIds?: string[],
3150
3217
  ) => {
3151
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3218
+ this.trackSubscriptionManager.setOverrides(
3152
3219
  resolution
3153
3220
  ? {
3154
3221
  enabled: true,
@@ -3157,7 +3224,7 @@ export class Call {
3157
3224
  : undefined,
3158
3225
  sessionIds,
3159
3226
  );
3160
- this.dynascaleManager.applyTrackSubscriptions();
3227
+ this.trackSubscriptionManager.apply();
3161
3228
  };
3162
3229
 
3163
3230
  /**
@@ -3165,10 +3232,10 @@ export class Call {
3165
3232
  * and removes any preference for preferred resolution.
3166
3233
  */
3167
3234
  setIncomingVideoEnabled = (enabled: boolean) => {
3168
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3235
+ this.trackSubscriptionManager.setOverrides(
3169
3236
  enabled ? undefined : { enabled: false },
3170
3237
  );
3171
- this.dynascaleManager.applyTrackSubscriptions();
3238
+ this.trackSubscriptionManager.apply();
3172
3239
  };
3173
3240
 
3174
3241
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+
5
+ import '../rtc/__tests__/mocks/webrtc.mocks';
6
+
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { Call } from '../Call';
9
+ import { StreamClient } from '../coordinator/connection/client';
10
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
11
+ import { StreamVideoWriteableStateStore } from '../store';
12
+
13
+ describe('Call lifecycle wiring', () => {
14
+ let call: Call;
15
+
16
+ beforeEach(() => {
17
+ call = new Call({
18
+ type: 'test',
19
+ id: generateUUIDv4(),
20
+ streamClient: new StreamClient('abc'),
21
+ clientStore: new StreamVideoWriteableStateStore(),
22
+ });
23
+ });
24
+
25
+ // Regression guard for the Call-owned helper teardown chain. Each of
26
+ // these helpers holds a resource (timer, listener, AudioContext) that
27
+ // leaks across calls if teardown is dropped during a refactor.
28
+ // Covers trackSubscriptionManager, audioBindingsWatchdog, and
29
+ // dynascaleManager. SFU-lifecycle disposables (publisher/subscriber/
30
+ // sfuStatsReporter) require a real join and are out of scope.
31
+ it('call.leave() tears down all Call-owned helpers exactly once', async () => {
32
+ const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
33
+ const audioBindingsDispose = vi.spyOn(
34
+ call.audioBindingsWatchdog!,
35
+ 'dispose',
36
+ );
37
+ const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
38
+
39
+ await call.leave();
40
+
41
+ expect(trackSubDispose).toHaveBeenCalledTimes(1);
42
+ expect(audioBindingsDispose).toHaveBeenCalledTimes(1);
43
+ expect(dynascaleDispose).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ // Order matters: the SFU subscription pump must finish tearing down
47
+ // before DynascaleManager closes its AudioContext, otherwise helpers
48
+ // can run on a closed context (logged as warnings or thrown by
49
+ // happy-dom). This is the contract the leave() teardown chain encodes.
50
+ it('call.leave() tears down helpers in the documented order', async () => {
51
+ const trackSubDispose = vi.spyOn(call.trackSubscriptionManager, 'dispose');
52
+ const audioBindingsDispose = vi.spyOn(
53
+ call.audioBindingsWatchdog!,
54
+ 'dispose',
55
+ );
56
+ const dynascaleDispose = vi.spyOn(call.dynascaleManager!, 'dispose');
57
+
58
+ await call.leave();
59
+
60
+ const trackSubOrder = trackSubDispose.mock.invocationCallOrder[0];
61
+ const audioBindingsOrder = audioBindingsDispose.mock.invocationCallOrder[0];
62
+ const dynascaleOrder = dynascaleDispose.mock.invocationCallOrder[0];
63
+
64
+ expect(trackSubOrder).toBeLessThan(audioBindingsOrder);
65
+ expect(audioBindingsOrder).toBeLessThan(dynascaleOrder);
66
+ });
67
+ });