react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29

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 (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -55,6 +55,23 @@
55
55
  case off
56
56
  case track
57
57
  case queue
58
+
59
+ init?(fromString string: String) {
60
+ switch string {
61
+ case "off": self = .off
62
+ case "track": self = .track
63
+ case "queue": self = .queue
64
+ default: return nil
65
+ }
66
+ }
67
+
68
+ var stringValue: String {
69
+ switch self {
70
+ case .off: "off"
71
+ case .track: "track"
72
+ case .queue: "queue"
73
+ }
74
+ }
58
75
  }
59
76
 
60
77
  enum CarPlaySiriListButtonPosition {
@@ -313,7 +330,7 @@
313
330
  }
314
331
  }
315
332
 
316
- // MARK: - Playback Event Types (used by PlaybackCoordinator)
333
+ // MARK: - Playback Event Types (used by Player)
317
334
 
318
335
  /// Public to match the generated Nitro enum, which `TrackPlayerError`
319
336
  /// exposes from a public property.
@@ -2,7 +2,7 @@ import Foundation
2
2
  import NitroModules
3
3
 
4
4
  /**
5
- * Update options for the TrackPlayer that can be changed at runtime.
5
+ * Update options for the player that can be changed at runtime.
6
6
  * These options control player behavior and capabilities that can be modified during playback.
7
7
  */
8
8
  class PlayerUpdateOptions {
@@ -3,62 +3,45 @@ import Foundation
3
3
  @preconcurrency import MediaPlayer
4
4
  import os.log
5
5
 
6
- // MPRemoteCommandCenter is not Sendable, but we only use it on the main thread
7
- extension MPRemoteCommandCenter: @retroactive @unchecked Sendable {}
8
-
9
6
  /// Controller for managing Now Playing info.
10
7
  ///
11
8
  /// A `MPNowPlayingSession` bound to the linked AVPlayer publishes now-playing info
12
9
  /// automatically: the system derives elapsed time, playback rate, and duration from
13
10
  /// the player, so the per-second clock stays out of our code (it extrapolates the
14
- /// scrubber). We supply the two things automatic publishing does NOT provide:
15
- /// metadata (title/artist/artwork), attached to `AVPlayerItem.nowPlayingInfo` (the
16
- /// supported channel under automatic publishing); and the explicit `playbackState`
17
- /// that CarPlay / Control Center read for their play/pause button (see `setPlaybackState`).
11
+ /// scrubber). We supply what automatic publishing does not: metadata (title/artist/
12
+ /// artwork), attached to `AVPlayerItem.nowPlayingInfo` (the supported channel under
13
+ /// automatic publishing). An explicit `playbackState` would be dropped: iOS ignores it
14
+ /// without a private entitlement (ADR 0005).
18
15
  @MainActor
19
16
  class NowPlayingInfoController {
20
17
  private let logger = Logger(subsystem: "com.audiobrowser", category: "NowPlayingInfoController")
21
18
 
22
19
  private var _info: [String: Any] = [:]
23
20
 
24
- /// Last play/pause state pushed to the session's center, to skip redundant writes.
25
- private var _playbackState: MPNowPlayingPlaybackState?
26
-
27
21
  /// The session bound to the linked AVPlayer; held so it stays alive while publishing.
28
22
  private var nowPlayingSession: MPNowPlayingSession?
29
23
 
30
24
  /// The linked AVPlayer — metadata is attached to its current item.
31
25
  private weak var linkedPlayer: AVPlayer?
32
26
 
33
- /// The current remote command center - the session's while linked, else the shared one.
34
- private(set) var remoteCommandCenter: MPRemoteCommandCenter = .shared()
35
-
36
27
  required init() {}
37
28
 
38
- /// Callback invoked when the remote command center changes (session created/destroyed).
39
- var onRemoteCommandCenterChanged: ((MPRemoteCommandCenter) -> Void)?
40
-
41
- /// Binds an AVPlayer to a new auto-publishing `MPNowPlayingSession`.
42
- ///
43
- /// - Important: This creates an `MPNowPlayingSession` with its own `remoteCommandCenter`;
44
- /// the `onRemoteCommandCenterChanged` callback is invoked with the new command center.
45
- func linkPlayer(_ player: AVPlayer) {
29
+ /// Binds an AVPlayer to a new auto-publishing `MPNowPlayingSession`, and returns the session's
30
+ /// own remote command center, which remote commands must now be enabled on.
31
+ func linkPlayer(_ player: AVPlayer) -> MPRemoteCommandCenter {
46
32
  logger.notice("Linking AVPlayer to MPNowPlayingSession for automatic publishing")
47
33
 
48
34
  linkedPlayer = player
49
35
 
50
36
  let session = MPNowPlayingSession(players: [player])
51
37
  nowPlayingSession = session
52
- _playbackState = nil // new center starts at .unknown; force the next write
53
- remoteCommandCenter = session.remoteCommandCenter
54
38
 
55
39
  session.automaticallyPublishesNowPlayingInfo = true
56
40
 
57
41
  session.becomeActiveIfPossible { success in
58
42
  self.logger.notice("MPNowPlayingSession becomeActiveIfPossible: \(success)")
59
43
  }
60
-
61
- onRemoteCommandCenterChanged?(remoteCommandCenter)
44
+ return session.remoteCommandCenter
62
45
  }
63
46
 
64
47
  /// Re-requests now-playing activation for the linked session.
@@ -77,17 +60,14 @@ class NowPlayingInfoController {
77
60
  }
78
61
  }
79
62
 
80
- /// Unlinks the AVPlayer, tearing down the session and restoring the shared command center.
63
+ /// Unlinks the AVPlayer and tears down the session with its command center. Remote commands
64
+ /// never fall back to the shared center, whose targets belong to the host app.
81
65
  func unlinkPlayer() {
82
66
  logger.info("Unlinking AVPlayer from MPNowPlayingSession")
83
67
 
84
68
  linkedPlayer = nil
85
69
  nowPlayingSession?.automaticallyPublishesNowPlayingInfo = false
86
70
  nowPlayingSession = nil
87
- _playbackState = nil
88
-
89
- remoteCommandCenter = MPRemoteCommandCenter.shared()
90
- onRemoteCommandCenterChanged?(remoteCommandCenter)
91
71
  }
92
72
 
93
73
  /// Keys the session derives from the player itself — we never set these manually,
@@ -112,22 +92,18 @@ class NowPlayingInfoController {
112
92
  }
113
93
  }
114
94
 
115
- /// Sets key-values and immediately updates Now Playing.
116
- /// Playback-dynamics keys (elapsed/duration/rate) are owned by the session and skipped.
117
- func set(keyValues: [NowPlayingInfoKeyValue]) {
95
+ /// Sets now-playing fields, keyed by their `MPMediaItemProperty*` / `MPNowPlayingInfoProperty*`
96
+ /// names (nil removes one), and publishes when any changed. Elapsed time, duration and rate
97
+ /// are the session's to publish and are ignored. Returns whether anything changed.
98
+ @discardableResult
99
+ func set(_ fields: [String: Any?]) -> Bool {
118
100
  var changed = false
119
- for kv in keyValues where !Self.autoPublishedKeys.contains(kv.key) && !valueUnchanged(kv.key, kv.value) {
120
- _info[kv.key] = kv.value
101
+ for (key, value) in fields where !Self.autoPublishedKeys.contains(key) && !valueUnchanged(key, value) {
102
+ _info[key] = value
121
103
  changed = true
122
104
  }
123
105
  if changed { performUpdate() }
124
- }
125
-
126
- /// Sets a single key-value and immediately updates Now Playing.
127
- func set(keyValue: NowPlayingInfoKeyValue) {
128
- guard !Self.autoPublishedKeys.contains(keyValue.key), !valueUnchanged(keyValue.key, keyValue.value) else { return }
129
- _info[keyValue.key] = keyValue.value
130
- performUpdate()
106
+ return changed
131
107
  }
132
108
 
133
109
  /// Attaches metadata to the current item — the supported channel under automatic publishing.
@@ -155,15 +131,4 @@ class NowPlayingInfoController {
155
131
  linkedPlayer?.currentItem?.nowPlayingInfo = nil
156
132
  nowPlayingSession?.nowPlayingInfoCenter.nowPlayingInfo = nil
157
133
  }
158
-
159
- /// Sets the play/pause state CarPlay / Control Center show for their transport
160
- /// button. Automatic publishing fills the info dict (metadata/elapsed/rate) but
161
- /// not the explicit `playbackState`, so the coordinator pushes the user's
162
- /// play/pause intent here on intent/state changes.
163
- func setPlaybackState(playing: Bool) {
164
- let state: MPNowPlayingPlaybackState = playing ? .playing : .paused
165
- guard state != _playbackState else { return }
166
- _playbackState = state
167
- nowPlayingSession?.nowPlayingInfoCenter.playbackState = state
168
- }
169
134
  }
@@ -0,0 +1,90 @@
1
+ @preconcurrency import AVFoundation
2
+
3
+ // Delivered on the main queue through AVPlayerItemMetadataOutputPushDelegate.
4
+ extension AVTimedMetadataGroup: @retroactive @unchecked Sendable {}
5
+
6
+ /// Observes one AVPlayerItem from creation to `invalidate()`: its status and buffer, the
7
+ /// end-of-item and stall notifications, and its timed metadata. KVO and notifications arrive off
8
+ /// the main thread; anything that lands after `invalidate()` is dropped, so an old item's late
9
+ /// failure never reaches the next item's handlers.
10
+ @MainActor
11
+ final class ItemObserver: NSObject {
12
+ enum Event {
13
+ case likelyToKeepUp(Bool)
14
+ case failed(Error?)
15
+ case playedToEnd
16
+ case failedToPlayToEnd(Error?)
17
+ /// The buffer emptied mid-playback.
18
+ case stalled
19
+ case timedMetadata([AVTimedMetadataGroup])
20
+ }
21
+
22
+ private let item: AVPlayerItem
23
+ private let onEvent: @MainActor (Event) -> Void
24
+ private var observations: [NSKeyValueObservation] = []
25
+ private var notificationTokens: [any NSObjectProtocol] = []
26
+ private let metadataOutput = AVPlayerItemMetadataOutput()
27
+ private(set) var isActive = true
28
+
29
+ init(item: AVPlayerItem, onEvent: @escaping @MainActor (Event) -> Void) {
30
+ self.item = item
31
+ self.onEvent = onEvent
32
+ super.init()
33
+ observations = [
34
+ item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in
35
+ let isLikely = item.isPlaybackLikelyToKeepUp
36
+ Task { @MainActor in self?.emit(.likelyToKeepUp(isLikely)) }
37
+ },
38
+ item.observe(\.status, options: [.new]) { [weak self] item, _ in
39
+ guard item.status == .failed else { return }
40
+ let error = item.error
41
+ Task { @MainActor in self?.emit(.failed(error)) }
42
+ },
43
+ ]
44
+ notificationTokens = [
45
+ observe(.AVPlayerItemDidPlayToEndTime) { _ in .playedToEnd },
46
+ observe(.AVPlayerItemFailedToPlayToEndTime) {
47
+ .failedToPlayToEnd($0.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error)
48
+ },
49
+ observe(.AVPlayerItemPlaybackStalled) { _ in .stalled },
50
+ ]
51
+ metadataOutput.setDelegate(self, queue: .main)
52
+ item.add(metadataOutput)
53
+ }
54
+
55
+ func invalidate() {
56
+ guard isActive else { return }
57
+ isActive = false
58
+ observations.removeAll()
59
+ for token in notificationTokens {
60
+ NotificationCenter.default.removeObserver(token)
61
+ }
62
+ notificationTokens = []
63
+ item.remove(metadataOutput)
64
+ }
65
+
66
+ private func observe(
67
+ _ name: Notification.Name,
68
+ _ event: @escaping @Sendable (Notification) -> Event,
69
+ ) -> any NSObjectProtocol {
70
+ NotificationCenter.default.addObserver(forName: name, object: item, queue: nil) { [weak self] notification in
71
+ let event = event(notification)
72
+ Task { @MainActor in self?.emit(event) }
73
+ }
74
+ }
75
+
76
+ private func emit(_ event: Event) {
77
+ guard isActive else { return }
78
+ onEvent(event)
79
+ }
80
+ }
81
+
82
+ extension ItemObserver: AVPlayerItemMetadataOutputPushDelegate {
83
+ nonisolated func metadataOutput(
84
+ _: AVPlayerItemMetadataOutput,
85
+ didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
86
+ from _: AVPlayerItemTrack?,
87
+ ) {
88
+ MainActor.assumeIsolated { emit(.timedMetadata(groups)) }
89
+ }
90
+ }
@@ -0,0 +1,59 @@
1
+ import AVFoundation
2
+
3
+ /// Observes one AVPlayer from creation to `invalidate()`: its status, its time-control status,
4
+ /// and the moment audio starts. A recreated player gets a new observer, so nothing a released
5
+ /// player reports reaches its successor.
6
+ @MainActor
7
+ final class PlayerObserver {
8
+ enum Event {
9
+ case status(AVPlayer.Status)
10
+ case timeControlStatus(AVPlayer.TimeControlStatus)
11
+ /// Playback crossed the first millisecond.
12
+ case audioDidStart
13
+ }
14
+
15
+ /// A boundary time cannot be zero.
16
+ private static let startBoundaryTime = CMTime(value: 1, timescale: 1000)
17
+
18
+ private let player: AVPlayer
19
+ private let onEvent: @MainActor (Event) -> Void
20
+ private var observations: [NSKeyValueObservation] = []
21
+ private(set) var boundaryObserverToken: Any?
22
+ private var isActive = true
23
+
24
+ init(player: AVPlayer, onEvent: @escaping @MainActor (Event) -> Void) {
25
+ self.player = player
26
+ self.onEvent = onEvent
27
+ observations = [
28
+ player.observe(\.status, options: [.new, .initial]) { [weak self] player, _ in
29
+ let status = player.status
30
+ Task { @MainActor in self?.emit(.status(status)) }
31
+ },
32
+ player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
33
+ let status = player.timeControlStatus
34
+ Task { @MainActor in self?.emit(.timeControlStatus(status)) }
35
+ },
36
+ ]
37
+ boundaryObserverToken = player.addBoundaryTimeObserver(
38
+ forTimes: [NSValue(time: Self.startBoundaryTime)], queue: nil,
39
+ ) { [weak self] in
40
+ Task { @MainActor in self?.emit(.audioDidStart) }
41
+ }
42
+ }
43
+
44
+ /// Stops all delivery, including events already hopping to the main actor. An AVPlayer
45
+ /// retains its time observers, so one left registered outlives the observer (#96).
46
+ func invalidate() {
47
+ isActive = false
48
+ observations.removeAll()
49
+ if let boundaryObserverToken {
50
+ player.removeTimeObserver(boundaryObserverToken)
51
+ self.boundaryObserverToken = nil
52
+ }
53
+ }
54
+
55
+ private func emit(_ event: Event) {
56
+ guard isActive else { return }
57
+ onEvent(event)
58
+ }
59
+ }
@@ -1,123 +1,72 @@
1
1
  import AVFoundation
2
- import Foundation
3
- import MediaPlayer
4
2
 
5
- enum SessionCategory: String {
6
- case playAndRecord, multiRoute, playback, ambient, soloAmbient
7
-
8
- func mapConfigToAVAudioSessionCategory() -> AVAudioSession.Category {
3
+ extension IOSCategory {
4
+ var avCategory: AVAudioSession.Category {
9
5
  switch self {
10
- case .playAndRecord:
11
- .playAndRecord
12
- case .multiRoute:
13
- .multiRoute
14
- case .playback:
15
- .playback
16
- case .ambient:
17
- .ambient
18
- case .soloAmbient:
19
- .soloAmbient
6
+ case .playback: .playback
7
+ case .playandrecord: .playAndRecord
8
+ case .multiroute: .multiRoute
9
+ case .ambient: .ambient
10
+ case .soloambient: .soloAmbient
11
+ case .record: .record
20
12
  }
21
13
  }
22
14
  }
23
15
 
24
- enum SessionCategoryPolicy: String {
25
- case `default`, longFormAudio, longFormVideo
26
-
27
- func toRouteSharingPolicy() -> AVAudioSession.RouteSharingPolicy {
16
+ extension IOSCategoryMode {
17
+ var avMode: AVAudioSession.Mode {
28
18
  switch self {
29
- case .default:
30
- .default
31
- case .longFormAudio:
32
- .longFormAudio
33
- case .longFormVideo:
34
- .longFormVideo
19
+ case .default: .default
20
+ case .gamechat: .gameChat
21
+ case .measurement: .measurement
22
+ case .movieplayback: .moviePlayback
23
+ case .spokenaudio: .spokenAudio
24
+ case .videochat: .videoChat
25
+ case .videorecording: .videoRecording
26
+ case .voicechat: .voiceChat
27
+ case .voiceprompt: .voicePrompt
35
28
  }
36
29
  }
37
30
  }
38
31
 
39
- enum SessionCategoryMode: String {
40
- case `default`, gameChat, measurement, moviePlayback, spokenAudio, videoChat, videoRecording,
41
- voiceChat, voicePrompt
42
-
43
- func mapConfigToAVAudioSessionCategoryMode() -> AVAudioSession.Mode {
32
+ extension IOSCategoryPolicy {
33
+ var avPolicy: AVAudioSession.RouteSharingPolicy {
44
34
  switch self {
45
- case .default:
46
- .default
47
- case .gameChat:
48
- .gameChat
49
- case .measurement:
50
- .measurement
51
- case .moviePlayback:
52
- .moviePlayback
53
- case .spokenAudio:
54
- .spokenAudio
55
- case .videoChat:
56
- .videoChat
57
- case .videoRecording:
58
- .videoRecording
59
- case .voiceChat:
60
- .voiceChat
61
- case .voicePrompt:
62
- .voicePrompt
35
+ case .default: .default
36
+ case .longformaudio: .longFormAudio
37
+ case .longformvideo: .longFormVideo
63
38
  }
64
39
  }
65
40
  }
66
41
 
67
- enum SessionCategoryOptions: String {
68
- case mixWithOthers, duckOthers, interruptSpokenAudioAndMixWithOthers, allowBluetooth,
69
- allowBluetoothA2DP,
70
- allowAirPlay,
71
- defaultToSpeaker
72
-
73
- func mapConfigToAVAudioSessionCategoryOptions() -> AVAudioSession.CategoryOptions? {
42
+ extension IOSCategoryOptions {
43
+ var avOptions: AVAudioSession.CategoryOptions {
74
44
  switch self {
75
- case .mixWithOthers:
76
- .mixWithOthers
77
- case .duckOthers:
78
- .duckOthers
79
- case .interruptSpokenAudioAndMixWithOthers:
80
- .interruptSpokenAudioAndMixWithOthers
81
- case .allowBluetooth:
82
- .allowBluetoothHFP
83
- case .allowBluetoothA2DP:
84
- .allowBluetoothA2DP
85
- case .allowAirPlay:
86
- .allowAirPlay
87
- case .defaultToSpeaker:
88
- .defaultToSpeaker
45
+ case .mixwithothers: .mixWithOthers
46
+ case .duckothers: .duckOthers
47
+ case .interruptspokenaudioandmixwithothers: .interruptSpokenAudioAndMixWithOthers
48
+ case .allowbluetooth: .allowBluetoothHFP
49
+ case .allowbluetootha2dp: .allowBluetoothA2DP
50
+ case .allowairplay: .allowAirPlay
51
+ case .defaulttospeaker: .defaultToSpeaker
89
52
  }
90
53
  }
91
54
  }
92
55
 
93
56
  extension NativeIOSSetupOptions {
94
- /// The resolved `AVAudioSession` category configuration from the iOS setup options. Each field
95
- /// falls back to a playback default when it's absent or unrecognised, so a consumer's
96
- /// `categoryOptions` (e.g. `allowBluetooth` / `allowAirPlay`), `mode`, and route `policy` are
97
- /// actually honoured at setup instead of being dropped.
57
+ /// The `AVAudioSession` configuration these options ask for; an absent field takes the
58
+ /// playback default.
98
59
  func resolveAudioSessionConfig() -> (
99
60
  category: AVAudioSession.Category,
100
61
  mode: AVAudioSession.Mode,
101
62
  policy: AVAudioSession.RouteSharingPolicy,
102
63
  options: AVAudioSession.CategoryOptions,
103
64
  ) {
104
- let category = self.category
105
- .flatMap { SessionCategory(rawValue: $0.stringValue) }?
106
- .mapConfigToAVAudioSessionCategory() ?? .playback
107
- let mode = self.categoryMode
108
- .flatMap { SessionCategoryMode(rawValue: $0.stringValue) }?
109
- .mapConfigToAVAudioSessionCategoryMode() ?? .default
110
- let policy = self.categoryPolicy
111
- .flatMap { SessionCategoryPolicy(rawValue: $0.stringValue) }?
112
- .toRouteSharingPolicy() ?? .default
113
- let options = (self.categoryOptions ?? [])
114
- .reduce(into: AVAudioSession.CategoryOptions()) { acc, option in
115
- if let mapped = SessionCategoryOptions(rawValue: option.stringValue)?
116
- .mapConfigToAVAudioSessionCategoryOptions()
117
- {
118
- acc.insert(mapped)
119
- }
120
- }
121
- return (category, mode, policy, options)
65
+ (
66
+ category?.avCategory ?? .playback,
67
+ categoryMode?.avMode ?? .default,
68
+ categoryPolicy?.avPolicy ?? .default,
69
+ AVAudioSession.CategoryOptions((categoryOptions ?? []).map(\.avOptions)),
70
+ )
122
71
  }
123
72
  }
@@ -0,0 +1,177 @@
1
+ import AVFoundation
2
+ import NitroModules
3
+
4
+ /// What `AudioSessionController` tells the player about the audio session.
5
+ @MainActor
6
+ protocol AudioSessionPlayer: AnyObject {
7
+ var playWhenReady: Bool { get }
8
+ var willResumeAfterInterruption: Bool { get }
9
+ func handleInterruptionBegan()
10
+ func handleInterruptionEnded(shouldResume: Bool)
11
+ func handleRouteDisconnected()
12
+ func mediaServicesWereReset()
13
+ }
14
+
15
+ /// Owns the app's `AVAudioSession`: its category, activation and release, and the interruption,
16
+ /// route-change, media-services-reset and volume observers, installed once at init.
17
+ ///
18
+ /// The session is configured at setup but activated only when playback produces output:
19
+ /// activating a non-mixable session interrupts other apps' audio, which must not happen at
20
+ /// launch.
21
+ @MainActor
22
+ final class AudioSessionController {
23
+ weak var player: AudioSessionPlayer?
24
+
25
+ let outputChanged = Emitter<Output>()
26
+ let volumeChanged = Emitter<Double>()
27
+
28
+ /// Nil until `configure`: before setup the category is the host app's, not ours to restore.
29
+ private var configuration: (
30
+ category: AVAudioSession.Category,
31
+ mode: AVAudioSession.Mode,
32
+ policy: AVAudioSession.RouteSharingPolicy,
33
+ options: AVAudioSession.CategoryOptions,
34
+ )?
35
+
36
+ private var releaseWork: DispatchWorkItem?
37
+
38
+ /// Touched only by `init` and `deinit`.
39
+ private nonisolated(unsafe) var observers: [NSObjectProtocol] = []
40
+ private var volumeObservation: NSKeyValueObservation?
41
+
42
+ private var session: AVAudioSession {
43
+ .sharedInstance()
44
+ }
45
+
46
+ init() {
47
+ let center = NotificationCenter.default
48
+ observers = [
49
+ center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) {
50
+ [weak self] notification in
51
+ let info = notification.userInfo
52
+ let type = info?[AVAudioSessionInterruptionTypeKey] as? UInt
53
+ let reason = info?[AVAudioSessionInterruptionReasonKey] as? UInt
54
+ let options = info?[AVAudioSessionInterruptionOptionKey] as? UInt
55
+ MainActor.assumeIsolated { self?.handleInterruption(type: type, reason: reason, options: options) }
56
+ },
57
+ center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) {
58
+ [weak self] notification in
59
+ let reason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
60
+ MainActor.assumeIsolated { self?.handleRouteChange(reason: reason) }
61
+ },
62
+ center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) {
63
+ [weak self] _ in
64
+ MainActor.assumeIsolated {
65
+ // The reset cleared the category and invalidated every player and session handle.
66
+ self?.applyCategory()
67
+ self?.player?.mediaServicesWereReset()
68
+ }
69
+ },
70
+ ]
71
+ volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, change in
72
+ guard let volume = change.newValue else { return }
73
+ Task { @MainActor in self?.volumeChanged.emit(Double(volume)) }
74
+ }
75
+ }
76
+
77
+ deinit {
78
+ for observer in observers {
79
+ NotificationCenter.default.removeObserver(observer)
80
+ }
81
+ }
82
+
83
+ /// Sets the session category from the iOS setup options, without activating it.
84
+ func configure(_ options: NativeIOSSetupOptions?) {
85
+ configuration = options?.resolveAudioSessionConfig() ?? (.playback, .default, .default, [])
86
+ applyCategory()
87
+ }
88
+
89
+ /// Activates the session for output and cancels a pending release.
90
+ func activate() {
91
+ try? session.setActive(true)
92
+ cancelRelease()
93
+ }
94
+
95
+ /// Releases the session after 3 s unless playback resumes, so other apps' audio can come back.
96
+ /// Debounced: iOS dislikes deactivating while audio trails off, and a quick pause → resume
97
+ /// should not thrash it.
98
+ func scheduleRelease() {
99
+ releaseWork?.cancel()
100
+ let release = DispatchWorkItem { [weak self] in
101
+ guard let self, player?.playWhenReady == false else { return }
102
+ releaseWork = nil
103
+ try? session.setActive(false, options: .notifyOthersOnDeactivation)
104
+ }
105
+ releaseWork = release
106
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
107
+ }
108
+
109
+ func cancelRelease() {
110
+ releaseWork?.cancel()
111
+ releaseWork = nil
112
+ }
113
+
114
+ nonisolated static var volume: Double {
115
+ Double(AVAudioSession.sharedInstance().outputVolume)
116
+ }
117
+
118
+ nonisolated static var currentOutput: Output? {
119
+ guard let output = AVAudioSession.sharedInstance().currentRoute.outputs.first else { return nil }
120
+ let (type, external): (OutputType, Bool) = switch output.portType {
121
+ case .builtInSpeaker: (.speaker, false)
122
+ case .builtInReceiver: (.receiver, false)
123
+ case .airPlay: (.airplay, true)
124
+ case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: (.bluetooth, true)
125
+ case .headphones: (.headphones, true)
126
+ case .carAudio: (.car, true)
127
+ case .HDMI: (.hdmi, true)
128
+ case .usbAudio: (.usb, true)
129
+ default: (.other, true)
130
+ }
131
+ return Output(type: type, name: output.portName, external: external)
132
+ }
133
+
134
+ // MARK: - Private
135
+
136
+ private func applyCategory() {
137
+ guard let configuration else { return }
138
+ try? session.setCategory(
139
+ configuration.category, mode: configuration.mode, policy: configuration.policy, options: configuration.options,
140
+ )
141
+ }
142
+
143
+ private func handleInterruption(type: UInt?, reason: UInt?, options: UInt?) {
144
+ guard let type = type.flatMap(AVAudioSession.InterruptionType.init(rawValue:)) else { return }
145
+ switch type {
146
+ case .began:
147
+ // iOS 17+ also begins one, never ended, when an output route disappears; the route
148
+ // change handles that.
149
+ if #available(iOS 17.0, *),
150
+ let reason,
151
+ AVAudioSession.InterruptionReason(rawValue: reason) == .routeDisconnected
152
+ {
153
+ return
154
+ }
155
+ player?.handleInterruptionBegan()
156
+ case .ended:
157
+ let shouldResume = options.map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) } ?? false
158
+ // Reactivate only when a resume follows: activating while staying paused would hold a
159
+ // non-mixable session with nothing to release it.
160
+ if shouldResume, player?.willResumeAfterInterruption == true {
161
+ try? session.setActive(true)
162
+ }
163
+ player?.handleInterruptionEnded(shouldResume: shouldResume)
164
+ @unknown default:
165
+ break
166
+ }
167
+ }
168
+
169
+ private func handleRouteChange(reason: UInt?) {
170
+ // The output disappeared (headphones unplugged, a speaker off): pause, as every media app
171
+ // does, rather than carry on from the built-in speaker.
172
+ if let reason, AVAudioSession.RouteChangeReason(rawValue: reason) == .oldDeviceUnavailable {
173
+ player?.handleRouteDisconnected()
174
+ }
175
+ if let output = Self.currentOutput { outputChanged.emit(output) }
176
+ }
177
+ }