@stream-io/video-client 1.41.0 → 1.41.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.
@@ -1,11 +1,14 @@
1
1
  import { Call } from '../Call';
2
2
  import { SpeakerState } from './SpeakerState';
3
+ import { CallSettingsResponse } from '../gen/coordinator';
3
4
  export declare class SpeakerManager {
4
5
  readonly state: SpeakerState;
5
6
  private subscriptions;
6
7
  private areSubscriptionsSetUp;
7
8
  private readonly call;
9
+ private defaultDevice?;
8
10
  constructor(call: Call);
11
+ apply(settings: CallSettingsResponse): void;
9
12
  setup(): void;
10
13
  /**
11
14
  * Lists the available audio output devices
@@ -5,6 +5,7 @@ import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signa
5
5
  import type { CallState } from '../store';
6
6
  import type { StreamSfuClient } from '../StreamSfuClient';
7
7
  import { SpeakerManager } from '../devices';
8
+ import { Tracer } from '../stats';
8
9
  type VideoTrackSubscriptionOverride = {
9
10
  enabled: true;
10
11
  dimension: VideoDimension;
@@ -34,6 +35,8 @@ export declare class DynascaleManager {
34
35
  private logger;
35
36
  private callState;
36
37
  private speaker;
38
+ private tracer;
39
+ private useWebAudio;
37
40
  private audioContext;
38
41
  private sfuClient;
39
42
  private pendingSubscriptionsUpdate;
@@ -53,7 +56,7 @@ export declare class DynascaleManager {
53
56
  /**
54
57
  * Creates a new DynascaleManager instance.
55
58
  */
56
- constructor(callState: CallState, speaker: SpeakerManager);
59
+ constructor(callState: CallState, speaker: SpeakerManager, tracer: Tracer);
57
60
  /**
58
61
  * Disposes the allocated resources and closes the audio context if it was created.
59
62
  */
@@ -79,6 +82,15 @@ export declare class DynascaleManager {
79
82
  * @param element the viewport element.
80
83
  */
81
84
  setViewport: <T extends HTMLElement>(element: T) => () => void;
85
+ /**
86
+ * Sets whether to use WebAudio API for audio playback.
87
+ * Must be set before joining the call.
88
+ *
89
+ * @internal
90
+ *
91
+ * @param useWebAudio whether to use WebAudio API.
92
+ */
93
+ setUseWebAudio: (useWebAudio: boolean) => void;
82
94
  /**
83
95
  * Binds a DOM <video> element to the given session id.
84
96
  * This method will make sure that the video element will play
@@ -0,0 +1,16 @@
1
+ export type AudioSessionState = 'inactive' | 'active' | 'interrupted';
2
+ export type AudioSessionType = 'auto' | 'playback' | 'transient' | 'transient-solo' | 'ambient' | 'play-and-record';
3
+ export interface AudioSession extends EventTarget {
4
+ type: AudioSessionType;
5
+ state: AudioSessionState;
6
+ onstatechange: EventListenerOrEventListenerObject;
7
+ }
8
+ declare global {
9
+ interface Navigator {
10
+ /**
11
+ * `audioSession` is available in Safari only. See:
12
+ * https://github.com/w3c/audio-session/blob/main/explainer.md
13
+ */
14
+ audioSession?: AudioSession;
15
+ }
16
+ }
@@ -285,8 +285,8 @@ export type StreamRNVideoSDKGlobals = {
285
285
  /**
286
286
  * Sets up the in call manager.
287
287
  */
288
- setup({ default_device, }: {
289
- default_device: AudioSettingsRequestDefaultDeviceEnum;
288
+ setup({ defaultDevice, }: {
289
+ defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
290
290
  }): void;
291
291
  /**
292
292
  * Starts the in call manager.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.41.0",
3
+ "version": "1.41.2",
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.1",
49
+ "@stream-io/audio-filters-web": "^0.7.2",
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
@@ -339,7 +339,11 @@ export class Call {
339
339
  this.microphone = new MicrophoneManager(this);
340
340
  this.speaker = new SpeakerManager(this);
341
341
  this.screenShare = new ScreenShareManager(this);
342
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker);
342
+ this.dynascaleManager = new DynascaleManager(
343
+ this.state,
344
+ this.speaker,
345
+ this.tracer,
346
+ );
343
347
  }
344
348
 
345
349
  /**
@@ -2694,9 +2698,7 @@ export class Call {
2694
2698
  settings: CallSettingsResponse,
2695
2699
  publish: boolean,
2696
2700
  ) => {
2697
- globalThis.streamRNVideoSDK?.callManager.setup({
2698
- default_device: settings.audio.default_device,
2699
- });
2701
+ this.speaker.apply(settings);
2700
2702
  await this.camera.apply(settings.video, publish).catch((err) => {
2701
2703
  this.logger.warn('Camera init failed', err);
2702
2704
  });
@@ -3,12 +3,17 @@ import { Call } from '../Call';
3
3
  import { isReactNative } from '../helpers/platforms';
4
4
  import { SpeakerState } from './SpeakerState';
5
5
  import { deviceIds$, getAudioOutputDevices } from './devices';
6
+ import {
7
+ CallSettingsResponse,
8
+ AudioSettingsRequestDefaultDeviceEnum,
9
+ } from '../gen/coordinator';
6
10
 
7
11
  export class SpeakerManager {
8
12
  readonly state: SpeakerState;
9
13
  private subscriptions: Subscription[] = [];
10
14
  private areSubscriptionsSetUp = false;
11
15
  private readonly call: Call;
16
+ private defaultDevice?: AudioSettingsRequestDefaultDeviceEnum;
12
17
 
13
18
  constructor(call: Call) {
14
19
  this.call = call;
@@ -16,6 +21,41 @@ export class SpeakerManager {
16
21
  this.setup();
17
22
  }
18
23
 
24
+ apply(settings: CallSettingsResponse) {
25
+ if (!isReactNative()) {
26
+ return;
27
+ }
28
+ /// Determines if the speaker should be enabled based on a priority hierarchy of
29
+ /// settings.
30
+ ///
31
+ /// The priority order is as follows:
32
+ /// 1. If video camera is set to be on by default, speaker is enabled
33
+ /// 2. If audio speaker is set to be on by default, speaker is enabled
34
+ /// 3. If the default audio device is set to speaker, speaker is enabled
35
+ ///
36
+ /// This ensures that the speaker state aligns with the most important user
37
+ /// preference or system requirement.
38
+ const speakerOnWithSettingsPriority =
39
+ settings.video.camera_default_on ||
40
+ settings.audio.speaker_default_on ||
41
+ settings.audio.default_device ===
42
+ AudioSettingsRequestDefaultDeviceEnum.SPEAKER;
43
+
44
+ const defaultDevice = speakerOnWithSettingsPriority
45
+ ? AudioSettingsRequestDefaultDeviceEnum.SPEAKER
46
+ : AudioSettingsRequestDefaultDeviceEnum.EARPIECE;
47
+
48
+ if (this.defaultDevice !== defaultDevice) {
49
+ this.call.logger.debug('SpeakerManager: setting default device', {
50
+ defaultDevice,
51
+ });
52
+ this.defaultDevice = defaultDevice;
53
+ globalThis.streamRNVideoSDK?.callManager.setup({
54
+ defaultDevice,
55
+ });
56
+ }
57
+ }
58
+
19
59
  setup() {
20
60
  if (this.areSubscriptionsSetUp) {
21
61
  return;
@@ -51,11 +91,7 @@ export class SpeakerManager {
51
91
  * @returns an Observable that will be updated if a device is connected or disconnected
52
92
  */
53
93
  listDevices() {
54
- if (isReactNative()) {
55
- throw new Error(
56
- 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
57
- );
58
- }
94
+ assertUnsupportedInReactNative();
59
95
  return getAudioOutputDevices(this.call.tracer);
60
96
  }
61
97
 
@@ -67,11 +103,7 @@ export class SpeakerManager {
67
103
  * @param deviceId empty string means the system default
68
104
  */
69
105
  select(deviceId: string) {
70
- if (isReactNative()) {
71
- throw new Error(
72
- 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
73
- );
74
- }
106
+ assertUnsupportedInReactNative();
75
107
  this.state.setDevice(deviceId);
76
108
  }
77
109
 
@@ -93,11 +125,7 @@ export class SpeakerManager {
93
125
  * Note: This method is not supported in React Native
94
126
  */
95
127
  setVolume(volume: number) {
96
- if (isReactNative()) {
97
- throw new Error(
98
- 'This feature is not supported in React Native. Please visit https://getstream.io/video/docs/reactnative/core/camera-and-microphone/#speaker-management for more details',
99
- );
100
- }
128
+ assertUnsupportedInReactNative();
101
129
  if (volume && (volume < 0 || volume > 1)) {
102
130
  throw new Error('Volume must be between 0 and 1');
103
131
  }
@@ -125,3 +153,11 @@ export class SpeakerManager {
125
153
  });
126
154
  }
127
155
  }
156
+
157
+ const assertUnsupportedInReactNative = () => {
158
+ if (isReactNative()) {
159
+ throw new Error(
160
+ 'Unsupported in React Native. See: https://getstream.io/video/docs/react-native/guides/camera-and-microphone/#speaker-management',
161
+ );
162
+ }
163
+ };
@@ -27,6 +27,7 @@ import type { StreamSfuClient } from '../StreamSfuClient';
27
27
  import { SpeakerManager } from '../devices';
28
28
  import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
29
29
  import { videoLoggerSystem } from '../logger';
30
+ import { Tracer } from '../stats';
30
31
 
31
32
  const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
32
33
  VideoTrackType,
@@ -69,6 +70,8 @@ export class DynascaleManager {
69
70
  private logger = videoLoggerSystem.getLogger('DynascaleManager');
70
71
  private callState: CallState;
71
72
  private speaker: SpeakerManager;
73
+ private tracer: Tracer;
74
+ private useWebAudio = isSafari();
72
75
  private audioContext: AudioContext | undefined;
73
76
  private sfuClient: StreamSfuClient | undefined;
74
77
  private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
@@ -113,9 +116,10 @@ export class DynascaleManager {
113
116
  /**
114
117
  * Creates a new DynascaleManager instance.
115
118
  */
116
- constructor(callState: CallState, speaker: SpeakerManager) {
119
+ constructor(callState: CallState, speaker: SpeakerManager, tracer: Tracer) {
117
120
  this.callState = callState;
118
121
  this.speaker = speaker;
122
+ this.tracer = tracer;
119
123
  }
120
124
 
121
125
  /**
@@ -190,6 +194,10 @@ export class DynascaleManager {
190
194
  override: VideoTrackSubscriptionOverride | undefined,
191
195
  sessionIds?: string[],
192
196
  ) => {
197
+ this.tracer.trace('setVideoTrackSubscriptionOverrides', [
198
+ override,
199
+ sessionIds,
200
+ ]);
193
201
  if (!sessionIds) {
194
202
  return setCurrentValue(
195
203
  this.videoTrackSubscriptionOverridesSubject,
@@ -297,6 +305,19 @@ export class DynascaleManager {
297
305
  return this.viewportTracker.setViewport(element);
298
306
  };
299
307
 
308
+ /**
309
+ * Sets whether to use WebAudio API for audio playback.
310
+ * Must be set before joining the call.
311
+ *
312
+ * @internal
313
+ *
314
+ * @param useWebAudio whether to use WebAudio API.
315
+ */
316
+ setUseWebAudio = (useWebAudio: boolean) => {
317
+ this.tracer.trace('setUseWebAudio', useWebAudio);
318
+ this.useWebAudio = useWebAudio;
319
+ };
320
+
300
321
  /**
301
322
  * Binds a DOM <video> element to the given session id.
302
323
  * This method will make sure that the video element will play
@@ -580,6 +601,7 @@ export class DynascaleManager {
580
601
  // we will play audio directly through the audio element in other browsers
581
602
  audioElement.muted = false;
582
603
  audioElement.play().catch((e) => {
604
+ this.tracer.trace('audioPlaybackError', e.message);
583
605
  this.logger.warn(`Failed to play audio stream`, e);
584
606
  });
585
607
  }
@@ -618,28 +640,54 @@ export class DynascaleManager {
618
640
  };
619
641
 
620
642
  private getOrCreateAudioContext = (): AudioContext | undefined => {
621
- if (this.audioContext || !isSafari()) return this.audioContext;
643
+ if (!this.useWebAudio) return;
644
+ if (this.audioContext) return this.audioContext;
622
645
  const context = new AudioContext();
646
+ this.tracer.trace('audioContext.create', context.state);
623
647
  if (context.state === 'suspended') {
624
648
  document.addEventListener('click', this.resumeAudioContext);
625
649
  }
626
- // @ts-expect-error audioSession is available in Safari only
650
+ context.addEventListener('statechange', () => {
651
+ this.tracer.trace('audioContext.state', context.state);
652
+ if (context.state === 'interrupted') {
653
+ this.resumeAudioContext();
654
+ }
655
+ });
656
+
627
657
  const audioSession = navigator.audioSession;
628
658
  if (audioSession) {
629
659
  // https://github.com/w3c/audio-session/blob/main/explainer.md
630
660
  audioSession.type = 'play-and-record';
661
+
662
+ let isSessionInterrupted = false;
663
+ audioSession.addEventListener('statechange', () => {
664
+ this.tracer.trace('audioSession.state', audioSession.state);
665
+ if (audioSession.state === 'interrupted') {
666
+ isSessionInterrupted = true;
667
+ } else if (isSessionInterrupted) {
668
+ this.resumeAudioContext();
669
+ isSessionInterrupted = false;
670
+ }
671
+ });
631
672
  }
632
673
  return (this.audioContext = context);
633
674
  };
634
675
 
635
676
  private resumeAudioContext = () => {
636
- if (this.audioContext?.state === 'suspended') {
637
- this.audioContext
638
- .resume()
639
- .catch((err) => this.logger.warn(`Can't resume audio context`, err))
640
- .then(() => {
677
+ if (!this.audioContext) return;
678
+ const { state } = this.audioContext;
679
+ if (state === 'suspended' || state === 'interrupted') {
680
+ const tag = 'audioContext.resume';
681
+ this.audioContext.resume().then(
682
+ () => {
683
+ this.tracer.trace(tag, this.audioContext?.state);
641
684
  document.removeEventListener('click', this.resumeAudioContext);
642
- });
685
+ },
686
+ (err) => {
687
+ this.tracer.trace(`${tag}Error`, this.audioContext?.state);
688
+ this.logger.warn(`Can't resume audio context`, err);
689
+ },
690
+ );
643
691
  }
644
692
  };
645
693
  }
@@ -34,7 +34,11 @@ describe('DynascaleManager', () => {
34
34
  clientStore: new StreamVideoWriteableStateStore(),
35
35
  });
36
36
  call.setSortParticipantsBy(noopComparator());
37
- dynascaleManager = new DynascaleManager(call.state, call.speaker);
37
+ dynascaleManager = new DynascaleManager(
38
+ call.state,
39
+ call.speaker,
40
+ call.tracer,
41
+ );
38
42
  });
39
43
 
40
44
  afterEach(() => {
@@ -108,6 +112,8 @@ describe('DynascaleManager', () => {
108
112
  };
109
113
  });
110
114
 
115
+ dynascaleManager.setUseWebAudio(false);
116
+
111
117
  videoElement = document.createElement('video');
112
118
 
113
119
  // circumvent happy-dom's extensive validation rules
@@ -189,6 +195,7 @@ describe('DynascaleManager', () => {
189
195
 
190
196
  it('audio: Safari should use AudioContext for audio playback', () => {
191
197
  globalThis._isSafari = true;
198
+ dynascaleManager.setUseWebAudio(true); // enabled by default on Safari
192
199
 
193
200
  vi.useFakeTimers();
194
201
  const audioElement = document.createElement('audio');
@@ -0,0 +1,26 @@
1
+ export type AudioSessionState = 'inactive' | 'active' | 'interrupted';
2
+
3
+ export type AudioSessionType =
4
+ | 'auto'
5
+ | 'playback'
6
+ | 'transient'
7
+ | 'transient-solo'
8
+ | 'ambient'
9
+ | 'play-and-record';
10
+
11
+ export interface AudioSession extends EventTarget {
12
+ type: AudioSessionType;
13
+ state: AudioSessionState;
14
+
15
+ onstatechange: EventListenerOrEventListenerObject;
16
+ }
17
+
18
+ declare global {
19
+ interface Navigator {
20
+ /**
21
+ * `audioSession` is available in Safari only. See:
22
+ * https://github.com/w3c/audio-session/blob/main/explainer.md
23
+ */
24
+ audioSession?: AudioSession;
25
+ }
26
+ }
@@ -118,6 +118,7 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
118
118
  setSinkId: vi.fn(async function (sinkId: string) {
119
119
  this.sinkId = sinkId;
120
120
  }),
121
+ addEventListener: vi.fn(),
121
122
  };
122
123
  });
123
124
  vi.stubGlobal('AudioContext', AudioContextMock);
package/src/types.ts CHANGED
@@ -351,9 +351,9 @@ export type StreamRNVideoSDKGlobals = {
351
351
  * Sets up the in call manager.
352
352
  */
353
353
  setup({
354
- default_device,
354
+ defaultDevice,
355
355
  }: {
356
- default_device: AudioSettingsRequestDefaultDeviceEnum;
356
+ defaultDevice: AudioSettingsRequestDefaultDeviceEnum;
357
357
  }): void;
358
358
 
359
359
  /**