@siteed/expo-audio-studio 2.12.3 → 2.13.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 (33) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/android/build.gradle +11 -0
  3. package/android/src/main/AndroidManifest.xml +8 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +266 -42
  5. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +55 -1
  6. package/app.plugin.js +3 -1
  7. package/build/cjs/AudioDeviceManager.js +225 -40
  8. package/build/cjs/AudioDeviceManager.js.map +1 -1
  9. package/build/cjs/hooks/useAudioDevices.js +30 -5
  10. package/build/cjs/hooks/useAudioDevices.js.map +1 -1
  11. package/build/cjs/useAudioRecorder.js +52 -8
  12. package/build/cjs/useAudioRecorder.js.map +1 -1
  13. package/build/esm/AudioDeviceManager.js +225 -40
  14. package/build/esm/AudioDeviceManager.js.map +1 -1
  15. package/build/esm/hooks/useAudioDevices.js +31 -6
  16. package/build/esm/hooks/useAudioDevices.js.map +1 -1
  17. package/build/esm/useAudioRecorder.js +53 -9
  18. package/build/esm/useAudioRecorder.js.map +1 -1
  19. package/build/types/AudioDeviceManager.d.ts +78 -2
  20. package/build/types/AudioDeviceManager.d.ts.map +1 -1
  21. package/build/types/hooks/useAudioDevices.d.ts +1 -0
  22. package/build/types/hooks/useAudioDevices.d.ts.map +1 -1
  23. package/build/types/useAudioRecorder.d.ts.map +1 -1
  24. package/ios/AudioDeviceManager.swift +21 -9
  25. package/ios/ExpoAudioStreamModule.swift +33 -1
  26. package/package.json +8 -6
  27. package/plugin/build/index.cjs +194 -0
  28. package/plugin/build/index.d.cts +1 -0
  29. package/plugin/build/index.js +7 -6
  30. package/plugin/src/index.ts +8 -8
  31. package/src/AudioDeviceManager.ts +286 -59
  32. package/src/hooks/useAudioDevices.ts +39 -6
  33. package/src/useAudioRecorder.tsx +102 -9
@@ -50,6 +50,26 @@ function mapRawDeviceToAudioDevice(rawDevice: any): AudioDevice {
50
50
 
51
51
  /**
52
52
  * Class that provides a cross-platform API for managing audio input devices
53
+ *
54
+ * EVENT API SPECIFICATION:
55
+ * ========================
56
+ *
57
+ * Device Events (deviceChangedEvent):
58
+ * {
59
+ * type: "deviceConnected" | "deviceDisconnected",
60
+ * deviceId: string
61
+ * }
62
+ *
63
+ * Recording Interruption Events (recordingInterruptedEvent):
64
+ * {
65
+ * reason: "userPaused" | "userResumed" | "audioFocusLoss" | "audioFocusGain" |
66
+ * "deviceFallback" | "deviceSwitchFailed" | "phoneCall" | "phoneCallEnded",
67
+ * isPaused: boolean,
68
+ * timestamp: number
69
+ * }
70
+ *
71
+ * NOTE: Device events use "type" field, interruption events use "reason" field.
72
+ * This is intentional to distinguish between different event categories.
53
73
  */
54
74
  export class AudioDeviceManager {
55
75
  private eventEmitter: InstanceType<typeof EventEmitter>
@@ -57,59 +77,76 @@ export class AudioDeviceManager {
57
77
  private availableDevices: AudioDevice[] = []
58
78
  private deviceChangeListeners: Set<(devices: AudioDevice[]) => void> =
59
79
  new Set()
60
- private deviceListeners: Set<() => void> = new Set()
80
+ private webDeviceChangeHandler?: () => void
61
81
  private lastRefreshTime: number = 0
62
82
  private refreshInProgress: boolean = false
63
83
  private refreshDebounceMs: number = 500 // Minimum 500ms between refreshes
64
84
  private logger?: ConsoleLike
65
85
 
86
+ // Track temporarily disconnected devices
87
+ private temporarilyDisconnectedDevices: Set<string> = new Set()
88
+ private disconnectionTimeouts: Map<string, NodeJS.Timeout> = new Map()
89
+ private readonly DISCONNECTION_TIMEOUT_MS = 5000 // 5 seconds
90
+
66
91
  constructor(options?: { logger?: ConsoleLike }) {
67
92
  this.eventEmitter = new EventEmitter(ExpoAudioStreamModule)
68
93
  this.logger = options?.logger
69
94
 
70
- // Listen for device change events from native modules if not on web
71
- if (Platform.OS !== 'web') {
72
- // Store the last event type to avoid duplicates
73
- let lastEventType: string | null = null
74
- let lastEventTime = 0
75
-
76
- this.eventEmitter.addListener(
77
- 'deviceChangedEvent',
78
- (event: any) => {
79
- // Skip processing duplicate events that occur too close together
80
- const now = Date.now()
81
- const isSimilarEvent =
82
- lastEventType === event.type &&
83
- now - lastEventTime < this.refreshDebounceMs
84
-
85
- if (isSimilarEvent) {
86
- this.logger?.debug(
87
- `Skipping similar device event (${event.type}) received too soon`
88
- )
89
- return
90
- }
91
-
92
- // Update the last event tracking
93
- lastEventType = event.type
94
- lastEventTime = now
95
-
96
- // Only refresh on meaningful events
97
- if (
98
- event.type === 'deviceConnected' ||
99
- event.type === 'deviceDisconnected' ||
100
- event.type === 'routeChanged'
101
- ) {
102
- this.logger?.debug(
103
- `Processing device event: ${event.type}`
104
- )
105
- // Refresh devices and notify listeners regardless of the direct return value
106
- this.refreshDevices()
107
- }
108
- }
109
- )
95
+ // Set up device event listeners for all platforms immediately
96
+ this.setupDeviceEventListeners()
97
+ }
98
+
99
+ /**
100
+ * Set up device event listeners for the current platform
101
+ */
102
+ private setupDeviceEventListeners(): void {
103
+ if (Platform.OS === 'web') {
104
+ this.setupWebDeviceChangeListener()
105
+ } else {
106
+ this.setupNativeDeviceEventListener()
110
107
  }
111
108
  }
112
109
 
110
+ /**
111
+ * Set up native device event listener for iOS/Android
112
+ */
113
+ private setupNativeDeviceEventListener(): void {
114
+ // Store the last event type to avoid duplicates
115
+ let lastEventType: string | null = null
116
+ let lastEventTime = 0
117
+
118
+ this.eventEmitter.addListener('deviceChangedEvent', (event: any) => {
119
+ // Skip processing duplicate events that occur too close together
120
+ const now = Date.now()
121
+ const isSimilarEvent =
122
+ lastEventType === event.type &&
123
+ now - lastEventTime < this.refreshDebounceMs
124
+
125
+ if (isSimilarEvent) {
126
+ this.logger?.debug(
127
+ `Skipping similar device event (${event.type}) received too soon`
128
+ )
129
+ return
130
+ }
131
+
132
+ // Update the last event tracking
133
+ lastEventType = event.type
134
+ lastEventTime = now
135
+
136
+ // Only refresh on meaningful events
137
+ if (
138
+ event.type === 'deviceConnected' ||
139
+ event.type === 'deviceDisconnected' ||
140
+ event.type === 'routeChanged'
141
+ ) {
142
+ this.logger?.debug(`Processing device event: ${event.type}`)
143
+ // Force refresh for device events to ensure fresh data
144
+ this.forceRefreshDevices()
145
+ }
146
+ })
147
+ this.logger?.debug('Native device event listener set up')
148
+ }
149
+
113
150
  /**
114
151
  * Initialize the device manager with a logger
115
152
  * @param logger A logger instance that implements the ConsoleLike interface
@@ -128,6 +165,36 @@ export class AudioDeviceManager {
128
165
  this.logger = logger
129
166
  }
130
167
 
168
+ /**
169
+ * Initialize or reinitialize device detection
170
+ * Useful for restarting device detection if initial setup failed
171
+ */
172
+ initializeDeviceDetection(): void {
173
+ this.logger?.debug('Initializing device detection...')
174
+
175
+ // Clean up existing listeners first
176
+ if (Platform.OS === 'web' && this.webDeviceChangeHandler) {
177
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
178
+ navigator.mediaDevices.removeEventListener(
179
+ 'devicechange',
180
+ this.webDeviceChangeHandler
181
+ )
182
+ }
183
+ this.webDeviceChangeHandler = undefined
184
+ }
185
+
186
+ // Re-setup device event listeners
187
+ this.setupDeviceEventListeners()
188
+ }
189
+
190
+ /**
191
+ * Get the current logger instance
192
+ * @returns The logger instance or undefined if not set
193
+ */
194
+ getLogger(): ConsoleLike | undefined {
195
+ return this.logger
196
+ }
197
+
131
198
  /**
132
199
  * Get all available audio input devices
133
200
  * @param options Optional settings to force refresh the device list. Can include a refresh flag.
@@ -278,6 +345,152 @@ export class AudioDeviceManager {
278
345
  }
279
346
  }
280
347
 
348
+ /**
349
+ * Mark a device as temporarily disconnected (for UI filtering)
350
+ * @param deviceId The ID of the device that was disconnected
351
+ * @param notify Whether to notify listeners immediately (default: true)
352
+ */
353
+ markDeviceAsDisconnected(deviceId: string, notify: boolean = true): void {
354
+ this.logger?.debug(
355
+ `Marking device ${deviceId} as temporarily disconnected`
356
+ )
357
+
358
+ // Clear any existing timeout for this device
359
+ const existingTimeout = this.disconnectionTimeouts.get(deviceId)
360
+ if (existingTimeout) {
361
+ clearTimeout(existingTimeout)
362
+ }
363
+
364
+ // Add to disconnected set
365
+ this.temporarilyDisconnectedDevices.add(deviceId)
366
+
367
+ // Set timeout to remove from disconnected set
368
+ const timeout = setTimeout(() => {
369
+ this.logger?.debug(
370
+ `Reconnection timeout expired for device ${deviceId}`
371
+ )
372
+ this.temporarilyDisconnectedDevices.delete(deviceId)
373
+ this.disconnectionTimeouts.delete(deviceId)
374
+ // Refresh devices to show the device again if it's still available
375
+ this.forceRefreshDevices()
376
+ }, this.DISCONNECTION_TIMEOUT_MS)
377
+
378
+ this.disconnectionTimeouts.set(deviceId, timeout)
379
+
380
+ // Only notify listeners if requested
381
+ if (notify) {
382
+ this.notifyListeners()
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Mark a device as reconnected (remove from disconnected set)
388
+ * @param deviceId The ID of the device that was reconnected
389
+ */
390
+ markDeviceAsReconnected(deviceId: string): void {
391
+ this.logger?.debug(`Marking device ${deviceId} as reconnected`)
392
+
393
+ // Clear timeout and remove from disconnected set
394
+ const timeout = this.disconnectionTimeouts.get(deviceId)
395
+ if (timeout) {
396
+ clearTimeout(timeout)
397
+ this.disconnectionTimeouts.delete(deviceId)
398
+ }
399
+
400
+ this.temporarilyDisconnectedDevices.delete(deviceId)
401
+
402
+ // Notify listeners with updated device list
403
+ this.notifyListeners()
404
+ }
405
+
406
+ /**
407
+ * Get filtered device list (excluding temporarily disconnected devices)
408
+ * @returns Array of available devices excluding temporarily disconnected ones
409
+ */
410
+ private getFilteredDevices(): AudioDevice[] {
411
+ if (this.temporarilyDisconnectedDevices.size === 0) {
412
+ return [...this.availableDevices]
413
+ }
414
+
415
+ const filtered = this.availableDevices.filter(
416
+ (device) => !this.temporarilyDisconnectedDevices.has(device.id)
417
+ )
418
+
419
+ this.logger?.debug(
420
+ `Filtered ${this.availableDevices.length - filtered.length} temporarily disconnected devices. ` +
421
+ `Showing ${filtered.length} devices.`
422
+ )
423
+
424
+ return filtered
425
+ }
426
+
427
+ /**
428
+ * Get the raw device list (including temporarily disconnected devices)
429
+ * @returns Array of all available devices from native layer
430
+ */
431
+ getRawDevices(): AudioDevice[] {
432
+ return [...this.availableDevices]
433
+ }
434
+
435
+ /**
436
+ * Get the IDs of temporarily disconnected devices
437
+ * @returns Set of device IDs that are temporarily hidden from UI
438
+ */
439
+ getTemporarilyDisconnectedDeviceIds(): ReadonlySet<string> {
440
+ return new Set(this.temporarilyDisconnectedDevices)
441
+ }
442
+
443
+ /**
444
+ * Clean up timeouts and listeners (useful for testing or cleanup)
445
+ */
446
+ cleanup(): void {
447
+ // Clear all disconnection timeouts
448
+ this.disconnectionTimeouts.forEach((timeout) => clearTimeout(timeout))
449
+ this.disconnectionTimeouts.clear()
450
+ this.temporarilyDisconnectedDevices.clear()
451
+
452
+ // Clear device change listeners
453
+ this.deviceChangeListeners.clear()
454
+
455
+ // Clean up web device listener
456
+ if (Platform.OS === 'web' && this.webDeviceChangeHandler) {
457
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
458
+ navigator.mediaDevices.removeEventListener(
459
+ 'devicechange',
460
+ this.webDeviceChangeHandler
461
+ )
462
+ }
463
+ this.webDeviceChangeHandler = undefined
464
+ }
465
+
466
+ this.logger?.debug('AudioDeviceManager cleanup completed')
467
+ }
468
+
469
+ /**
470
+ * Force refresh devices without debouncing (for device events)
471
+ * @returns Promise resolving to the updated device list (AudioDevice[])
472
+ */
473
+ async forceRefreshDevices(): Promise<AudioDevice[]> {
474
+ this.logger?.debug('Force refreshing devices (bypassing debounce)...')
475
+ this.refreshInProgress = true
476
+ try {
477
+ // Force fetch the latest devices from native layer
478
+ const devices = await this.getAvailableDevices({ refresh: true })
479
+ // Update internal state
480
+ this.availableDevices = devices
481
+ // Notify listeners with fresh data
482
+ this.notifyListeners()
483
+ this.lastRefreshTime = Date.now()
484
+ return devices
485
+ } catch (error) {
486
+ this.logger?.error('Error during forceRefreshDevices:', error)
487
+ return this.availableDevices
488
+ } finally {
489
+ this.refreshInProgress = false
490
+ this.logger?.debug('Force refresh finished.')
491
+ }
492
+ }
493
+
281
494
  /**
282
495
  * Refresh the list of available devices with debouncing and notify listeners.
283
496
  * @returns Promise resolving to the updated device list (AudioDevice[])
@@ -385,7 +598,6 @@ export class AudioDeviceManager {
385
598
  finalDevices = [DEFAULT_DEVICE]
386
599
  }
387
600
 
388
- this.setupWebDeviceChangeListener()
389
601
  this.availableDevices = finalDevices // Update internal state
390
602
  return finalDevices
391
603
  } catch (error) {
@@ -421,27 +633,39 @@ export class AudioDeviceManager {
421
633
  /**
422
634
  * Setup listener for device changes in web environment
423
635
  */
424
- private setupWebDeviceChangeListener() {
636
+ private setupWebDeviceChangeListener(): void {
425
637
  if (
426
638
  typeof navigator === 'undefined' ||
427
639
  !navigator.mediaDevices ||
428
- this.deviceListeners.size > 0 // Avoid adding multiple listeners
640
+ this.webDeviceChangeHandler // Avoid adding multiple listeners
429
641
  ) {
642
+ this.logger?.debug(
643
+ 'Web device change listener not available or already set up'
644
+ )
430
645
  return
431
646
  }
432
647
 
433
- const handleDeviceChange = () => {
434
- this.logger?.debug('Web device change detected.')
435
- // Refresh devices on change
436
- this.refreshDevices()
437
- }
648
+ try {
649
+ this.webDeviceChangeHandler = () => {
650
+ this.logger?.debug(
651
+ 'Web device change detected, refreshing device list'
652
+ )
653
+ // Force refresh to get immediate updates
654
+ this.forceRefreshDevices()
655
+ }
438
656
 
439
- navigator.mediaDevices.addEventListener(
440
- 'devicechange',
441
- handleDeviceChange
442
- )
443
- this.deviceListeners.add(handleDeviceChange)
444
- this.logger?.debug('Web device change listener added.')
657
+ navigator.mediaDevices.addEventListener(
658
+ 'devicechange',
659
+ this.webDeviceChangeHandler
660
+ )
661
+ this.logger?.debug('Web device change listener successfully set up')
662
+ } catch (error) {
663
+ this.logger?.warn(
664
+ 'Failed to set up web device change listener:',
665
+ error
666
+ )
667
+ this.webDeviceChangeHandler = undefined
668
+ }
445
669
  }
446
670
 
447
671
  /**
@@ -549,12 +773,15 @@ export class AudioDeviceManager {
549
773
  /**
550
774
  * Notify all registered listeners about device changes.
551
775
  */
552
- private notifyListeners(): void {
553
- // Pass a copy of the current devices array to listeners
554
- const devicesCopy = [...this.availableDevices]
776
+ notifyListeners(): void {
777
+ // Pass a copy of the filtered devices array to listeners
778
+ const devicesCopy = this.getFilteredDevices()
779
+
555
780
  this.logger?.debug(
556
- `Notifying ${this.deviceChangeListeners.size} listeners with ${devicesCopy.length} devices.`
781
+ `Notifying ${this.deviceChangeListeners.size} listeners with ${devicesCopy.length} devices ` +
782
+ `(${this.temporarilyDisconnectedDevices.size} temporarily hidden)`
557
783
  )
784
+
558
785
  this.deviceChangeListeners.forEach((listener) => {
559
786
  try {
560
787
  listener(devicesCopy)
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react'
1
+ import { useCallback, useEffect, useState, useId } from 'react'
2
2
 
3
3
  import { audioDeviceManager } from '../AudioDeviceManager'
4
4
  import { AudioDevice } from '../ExpoAudioStream.types'
@@ -12,6 +12,9 @@ export function useAudioDevices() {
12
12
  const [loading, setLoading] = useState(true)
13
13
  const [error, setError] = useState<Error | null>(null)
14
14
 
15
+ // Generate unique instance ID for debugging
16
+ const instanceId = useId().replace(/:/g, '').slice(0, 5)
17
+
15
18
  // Load devices on mount
16
19
  useEffect(() => {
17
20
  let isMounted = true
@@ -30,7 +33,9 @@ export function useAudioDevices() {
30
33
  const device = await audioDeviceManager.getCurrentDevice()
31
34
  if (isMounted) setCurrentDevice(device)
32
35
  } catch (err) {
33
- console.error('Failed to load audio devices:', err)
36
+ audioDeviceManager
37
+ .getLogger()
38
+ ?.error('Failed to load audio devices:', err)
34
39
  if (isMounted)
35
40
  setError(
36
41
  err instanceof Error
@@ -47,6 +52,12 @@ export function useAudioDevices() {
47
52
  // Set up device change listener
48
53
  const removeListener = audioDeviceManager.addDeviceChangeListener(
49
54
  (updatedDevices: AudioDevice[]) => {
55
+ audioDeviceManager
56
+ .getLogger()
57
+ ?.debug(
58
+ `🎛️ useAudioDevices [${instanceId}] received device change. Count: ${updatedDevices.length}`
59
+ )
60
+
50
61
  if (isMounted) {
51
62
  setDevices(updatedDevices)
52
63
 
@@ -57,10 +68,17 @@ export function useAudioDevices() {
57
68
  (d: AudioDevice) => d.id === currentDevice.id
58
69
  )
59
70
  ) {
71
+ audioDeviceManager
72
+ .getLogger()
73
+ ?.debug(
74
+ `🎛️ useAudioDevices [${instanceId}] Current device ${currentDevice.id} no longer available, updating`
75
+ )
60
76
  audioDeviceManager
61
77
  .getCurrentDevice()
62
78
  .then((newDevice: AudioDevice | null) => {
63
- if (isMounted) setCurrentDevice(newDevice)
79
+ if (isMounted) {
80
+ setCurrentDevice(newDevice)
81
+ }
64
82
  })
65
83
  }
66
84
  }
@@ -94,7 +112,9 @@ export function useAudioDevices() {
94
112
 
95
113
  return success
96
114
  } catch (err) {
97
- console.error('Failed to select audio device:', err)
115
+ audioDeviceManager
116
+ .getLogger()
117
+ ?.error('Failed to select audio device:', err)
98
118
  setError(
99
119
  err instanceof Error
100
120
  ? err
@@ -127,7 +147,9 @@ export function useAudioDevices() {
127
147
 
128
148
  return success
129
149
  } catch (err) {
130
- console.error('Failed to reset to default audio device:', err)
150
+ audioDeviceManager
151
+ .getLogger()
152
+ ?.error('Failed to reset to default audio device:', err)
131
153
  setError(
132
154
  err instanceof Error
133
155
  ? err
@@ -156,7 +178,9 @@ export function useAudioDevices() {
156
178
 
157
179
  return updatedDevices
158
180
  } catch (err) {
159
- console.error('Failed to refresh audio devices:', err)
181
+ audioDeviceManager
182
+ .getLogger()
183
+ ?.error('Failed to refresh audio devices:', err)
160
184
  setError(
161
185
  err instanceof Error
162
186
  ? err
@@ -168,6 +192,14 @@ export function useAudioDevices() {
168
192
  }
169
193
  }, [])
170
194
 
195
+ /**
196
+ * Initialize device detection
197
+ * Useful for restarting device detection if it failed initially
198
+ */
199
+ const initializeDeviceDetection = useCallback(() => {
200
+ audioDeviceManager.initializeDeviceDetection()
201
+ }, [])
202
+
171
203
  return {
172
204
  devices,
173
205
  currentDevice,
@@ -176,5 +208,6 @@ export function useAudioDevices() {
176
208
  selectDevice,
177
209
  resetToDefaultDevice,
178
210
  refreshDevices,
211
+ initializeDeviceDetection,
179
212
  }
180
213
  }