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
@@ -19,18 +19,22 @@ internal class ServiceBinding(private val context: Context) {
19
19
  var isBound: Boolean = false
20
20
  private set
21
21
 
22
+ @Volatile private var bindSucceeded: Boolean = false
23
+
22
24
  /**
23
- * Binds if not already bound, and returns whether a binding is held afterwards. A second call
24
- * while bound is a no-op: Android would otherwise count the extra bind and expect a matching
25
+ * Binds if not already bound, and returns whether the service will connect: what
26
+ * [Context.bindService] returned for the binding held. A second call while bound is a no-op that
27
+ * returns the same answer: Android would otherwise count the extra bind and expect a matching
25
28
  * unbind.
26
29
  */
27
30
  fun bind(intent: Intent, connection: ServiceConnection): Boolean {
28
- if (isBound) return true
31
+ if (isBound) return bindSucceeded
29
32
  // Marked bound whatever the result: Android registers the connection before it asks the
30
33
  // ActivityManager, so a false return still leaves one to release — "regardless of the return
31
34
  // value, you should later call unbindService()".
32
35
  isBound = true
33
- return context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
36
+ bindSucceeded = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
37
+ return bindSucceeded
34
38
  }
35
39
 
36
40
  /**
@@ -14,10 +14,9 @@ import timber.log.Timber
14
14
 
15
15
  /**
16
16
  * Outbound URL resolution for media and artwork — the Android analog of iOS
17
- * `BrowserManager+URLResolution.swift`. Owns how a Track's `src` / `artwork` becomes a fetchable
18
- * request: request layer (shared, incl. its transform) → kind config (media / artwork /
19
- * nowPlayingArtwork) → per-Track Resolve, with transform-wins semantics centralised in
20
- * [RequestConfigBuilder].
17
+ * `RequestPipeline.swift`. Owns how a Track's `src` / `artwork` becomes a fetchable request:
18
+ * request layer (shared, incl. its transform) → kind config (media / artwork / nowPlayingArtwork) →
19
+ * per-Track Resolve, with transform-wins semantics centralised in [RequestConfigBuilder].
21
20
  */
22
21
 
23
22
  /**
@@ -74,6 +74,19 @@ class ServiceBindingTest {
74
74
  assertEquals(1, shadowOf(app).unboundServiceConnections.size)
75
75
  }
76
76
 
77
+ /**
78
+ * setupPlayer binds after the auto-bind at construction already has. If that first bind failed,
79
+ * the service never connects, so setup must hear false and reject rather than wait for it.
80
+ */
81
+ @Test
82
+ fun bindingAgainAfterAFailedBindReportsTheFailure() {
83
+ val binding = ServiceBinding(app)
84
+ shadowOf(app).declareActionUnbindable("unbindable")
85
+
86
+ assertFalse(binding.bind(Intent("unbindable"), connection))
87
+ assertFalse(binding.bind(Intent("unbindable"), connection))
88
+ }
89
+
77
90
  @Test
78
91
  fun unbindIsIdempotent() {
79
92
  val binding = ServiceBinding(app)
@@ -15,8 +15,8 @@ import org.junit.Test
15
15
 
16
16
  /**
17
17
  * Tests browser/BrowserUrlResolution.kt — media/artwork URL resolution owned by BrowserManager (the
18
- * Kotlin analog of iOS BrowserManager+URLResolution.swift). Static configs only (Nitro Promises are
19
- * JNI-backed; see file-top note in BrowserManagerLayerResolutionTest).
18
+ * Kotlin analog of iOS RequestPipeline.swift). Static configs only (Nitro Promises are JNI-backed;
19
+ * see file-top note in BrowserManagerLayerResolutionTest).
20
20
  */
21
21
  class BrowserUrlResolutionTest {
22
22
 
@@ -0,0 +1,334 @@
1
+ @preconcurrency import AVFoundation
2
+ import Foundation
3
+ import MediaPlayer
4
+ import NitroModules
5
+ import os.log
6
+
7
+ /// The `PlayerEngine` over AVFoundation: the AVPlayer and its observers, media loading, the
8
+ /// now-playing session and the remote commands. Carries out what the `Player` decides; holds
9
+ /// no playback policy.
10
+ @MainActor
11
+ final class AVPlayerEngine: PlayerEngine {
12
+ let logger = Logger(subsystem: "com.audiobrowser", category: "AVPlayerEngine")
13
+
14
+ var onEvent: (@MainActor (EngineEvent) -> Void)?
15
+ private weak var callbacks: AVPlayerEngineCallbacks?
16
+
17
+ let nowPlayingInfoController: NowPlayingInfoController
18
+ let nowPlayingUpdater: NowPlayingUpdater
19
+ let remoteCommandController: RemoteCommandController
20
+ private let mediaLoader = MediaLoader()
21
+ private let silentAudioPrimer = SilentAudioPrimer()
22
+
23
+ private var avPlayer = AVPlayer()
24
+ private var playerObserver: PlayerObserver?
25
+ private var itemObserver: ItemObserver?
26
+ private var rate: Float = 1
27
+ private var skipAvailability = (canNext: false, canPrevious: false)
28
+
29
+ var remoteCommands: [RemoteCommand] = [] {
30
+ didSet { remoteCommandController.enable(commands: remoteCommands) }
31
+ }
32
+
33
+ /// - Parameter resolver: Resolves media URLs and now-playing artwork.
34
+ init(
35
+ callbacks: AVPlayerEngineCallbacks?,
36
+ resolver: MediaResolving?,
37
+ nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
38
+ ) {
39
+ self.callbacks = callbacks
40
+ self.nowPlayingInfoController = nowPlayingInfoController
41
+ nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
42
+ remoteCommandController = RemoteCommandController(callbacks: callbacks)
43
+
44
+ mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
45
+ mediaLoader.mediaUrlResolver = { [weak resolver] src, track in
46
+ await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
47
+ }
48
+ nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
49
+ await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
50
+ }
51
+ nowPlayingUpdater.onChanged = { [weak self] in self?.callbacks?.playerDidChangeNowPlaying($0) }
52
+ remoteCommandController.skipAvailability = { [weak self] in
53
+ self?.skipAvailability ?? (false, false)
54
+ }
55
+ setUpAVPlayer()
56
+ }
57
+
58
+ /// Releases the player for good: the now-playing session, the observers and the remote
59
+ /// commands go with it.
60
+ func destroy() {
61
+ remoteCommandController.disableAll()
62
+ nowPlayingInfoController.unlinkPlayer()
63
+ playerObserver?.invalidate()
64
+ playerObserver = nil
65
+ stopObservingItem()
66
+ }
67
+
68
+ /// Re-requests the now-playing election, which the system may have declined at link time while
69
+ /// the audio session was still inactive.
70
+ func reactivateNowPlayingSession() {
71
+ nowPlayingInfoController.reactivateSessionIfNeeded()
72
+ }
73
+
74
+ // MARK: - Buffering
75
+
76
+ var bufferDuration: Double = 0 {
77
+ didSet {
78
+ avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
79
+ mediaLoader.bufferDuration = bufferDuration
80
+ }
81
+ }
82
+
83
+ // MARK: - The Current Item
84
+
85
+ var hasItem: Bool {
86
+ avPlayer.currentItem != nil
87
+ }
88
+
89
+ var hasLoadedAsset: Bool {
90
+ mediaLoader.asset != nil
91
+ }
92
+
93
+ var currentTime: Double {
94
+ let seconds = avPlayer.currentTime().seconds
95
+ return seconds.isNaN ? 0 : seconds
96
+ }
97
+
98
+ /// The current item's duration, or 0 when it has none (a live stream). Not
99
+ /// `item.asset.duration`: deprecated since iOS 16, and it blocks the main actor fetching a
100
+ /// remote asset's duration.
101
+ var duration: Double {
102
+ guard let item = avPlayer.currentItem else { return 0 }
103
+ if !item.duration.seconds.isNaN {
104
+ return item.duration.seconds
105
+ }
106
+ if let seekable = item.seekableTimeRanges.last?.timeRangeValue.duration.seconds, !seekable.isNaN {
107
+ return seekable
108
+ }
109
+ return 0
110
+ }
111
+
112
+ var bufferedPosition: Double {
113
+ avPlayer.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0
114
+ }
115
+
116
+ var resumePosition: Double? {
117
+ guard let item = avPlayer.currentItem, !item.duration.isIndefinite else { return nil }
118
+ return item.currentTime().seconds
119
+ }
120
+
121
+ var liveEdge: Double? {
122
+ guard let range = avPlayer.currentItem?.seekableTimeRanges.last?.timeRangeValue,
123
+ range.duration.seconds > 0
124
+ else { return nil }
125
+ return range.end.seconds
126
+ }
127
+
128
+ var isPlaybackLikelyToKeepUp: Bool {
129
+ avPlayer.currentItem?.isPlaybackLikelyToKeepUp ?? false
130
+ }
131
+
132
+ var isAirPlayRoute: Bool {
133
+ AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .airPlay }
134
+ }
135
+
136
+ var volume: Float {
137
+ get { avPlayer.volume }
138
+ set { avPlayer.volume = newValue }
139
+ }
140
+
141
+ // MARK: - Loading
142
+
143
+ func resolveAndLoad(src: String, track: Track) {
144
+ mediaLoader.resolveAndLoad(src: src, track: track)
145
+ }
146
+
147
+ func loadResolved() {
148
+ mediaLoader.loadAsset()
149
+ }
150
+
151
+ func cancelLoading() {
152
+ mediaLoader.cancelAll()
153
+ }
154
+
155
+ func dropAsset() {
156
+ stopObservingItem()
157
+ mediaLoader.clearAsset()
158
+ }
159
+
160
+ func clearItem() {
161
+ dropAsset()
162
+ avPlayer.replaceCurrentItem(with: nil)
163
+ }
164
+
165
+ func stopObservingItem() {
166
+ itemObserver?.invalidate()
167
+ itemObserver = nil
168
+ }
169
+
170
+ func recreate() {
171
+ playerObserver?.invalidate()
172
+ nowPlayingInfoController.unlinkPlayer()
173
+ avPlayer = AVPlayer()
174
+ setUpAVPlayer()
175
+ }
176
+
177
+ private func setUpAVPlayer() {
178
+ avPlayer.allowsExternalPlayback = false
179
+ avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
180
+ playerObserver = PlayerObserver(player: avPlayer) { [weak self] in self?.playerDidEmit($0) }
181
+ remoteCommandController.switchCommandCenter(nowPlayingInfoController.linkPlayer(avPlayer))
182
+ avPlayer.defaultRate = rate
183
+ }
184
+
185
+ // MARK: - Transport
186
+
187
+ func play() {
188
+ avPlayer.play()
189
+ if rate != 1 { avPlayer.rate = rate }
190
+ }
191
+
192
+ func pause() {
193
+ avPlayer.pause()
194
+ }
195
+
196
+ func setRate(_ rate: Float, playing: Bool) {
197
+ self.rate = rate
198
+ avPlayer.defaultRate = rate
199
+ if playing { avPlayer.rate = rate }
200
+ }
201
+
202
+ func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void) {
203
+ avPlayer.seek(
204
+ to: CMTime(seconds: seconds, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero,
205
+ ) { finished in
206
+ Task { @MainActor in completion(finished) }
207
+ }
208
+ }
209
+
210
+ func applyTimePitchAlgorithm() {
211
+ avPlayer.currentItem?.audioTimePitchAlgorithm = .timeDomain
212
+ }
213
+
214
+ // MARK: - Now Playing and Remote Commands
215
+
216
+ func primeNowPlaying() {
217
+ silentAudioPrimer.prime()
218
+ }
219
+
220
+ func loadNowPlayingMetadata(for track: Track) {
221
+ nowPlayingUpdater.loadMetaValues(for: track)
222
+ }
223
+
224
+ func clearNowPlaying() {
225
+ nowPlayingUpdater.clearNowPlaying()
226
+ }
227
+
228
+ func updateRemoteRepeatMode(_ mode: RepeatMode) {
229
+ remoteCommandController.updateRepeatMode(mode)
230
+ }
231
+
232
+ func updateRemoteShuffleMode(_ enabled: Bool) {
233
+ remoteCommandController.updateShuffleMode(enabled)
234
+ }
235
+
236
+ func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
237
+ skipAvailability = (canNext, canPrevious)
238
+ remoteCommandController.refreshSkipAvailability()
239
+ }
240
+
241
+ // MARK: - Observed Events
242
+
243
+ private func playerDidEmit(_ event: PlayerObserver.Event) {
244
+ switch event {
245
+ case let .status(status):
246
+ if status == .failed { reportFailure(nil) }
247
+ case let .timeControlStatus(status):
248
+ let mapped: PlayerTimeControlStatus
249
+ switch status {
250
+ case .paused: mapped = .paused
251
+ case .waitingToPlayAtSpecifiedRate: mapped = .waitingToPlayAtSpecifiedRate
252
+ case .playing: mapped = .playing
253
+ @unknown default: return
254
+ }
255
+ onEvent?(.timeControlStatus(mapped, waitingForNoItem: avPlayer.reasonForWaitingToPlay == .noItemToPlay))
256
+ case .audioDidStart:
257
+ onEvent?(.audioDidStart)
258
+ }
259
+ }
260
+
261
+ private func itemDidEmit(_ event: ItemObserver.Event) {
262
+ switch event {
263
+ case let .likelyToKeepUp(isLikely): onEvent?(.likelyToKeepUp(isLikely))
264
+ case let .failed(error), let .failedToPlayToEnd(error): reportFailure(error)
265
+ case .playedToEnd: onEvent?(.playedToEnd)
266
+ case .stalled: onEvent?(.stalled)
267
+ case let .timedMetadata(groups): callbacks?.playerDidReceiveTimedMetadata(groups)
268
+ }
269
+ }
270
+
271
+ /// The item's own error stands in when the report carried none.
272
+ private func reportFailure(_ error: Error?) {
273
+ onEvent?(.failed(error ?? avPlayer.currentItem?.error, httpStatusCode: currentItemHttpStatusCode))
274
+ }
275
+
276
+ /// The HTTP status the server last answered the current item with. `AVPlayerItem.error` never
277
+ /// carries it (a 404 arrives as an opaque CoreMedia error), so the per-item error log is the
278
+ /// only place it surfaces.
279
+ private var currentItemHttpStatusCode: Int? {
280
+ guard let events = avPlayer.currentItem?.errorLog()?.events else { return nil }
281
+ return PlaybackFailure.httpStatusCode(fromErrorStatusCodes: events.map(\.errorStatusCode))
282
+ }
283
+
284
+ private func mediaLoaderDidEmit(_ event: MediaLoader.Event) {
285
+ switch event {
286
+ case let .prepared(item):
287
+ nowPlayingInfoController.prepareItem(item)
288
+ avPlayer.replaceCurrentItem(with: item)
289
+ itemObserver?.invalidate()
290
+ itemObserver = ItemObserver(item: item) { [weak self] in self?.itemDidEmit($0) }
291
+ onEvent?(.itemPrepared(likelyToKeepUp: item.isPlaybackLikelyToKeepUp))
292
+ case let .failed(error):
293
+ attachPublishingCarrierItemIfNeeded()
294
+ onEvent?(.loadFailed(error))
295
+ case .unplayable:
296
+ attachPublishingCarrierItemIfNeeded()
297
+ onEvent?(.unplayable)
298
+ case let .invalidSource(url):
299
+ // No carrier: an invalid src never produced an asset.
300
+ onEvent?(.invalidSource(url))
301
+ case let .commonMetadata(items):
302
+ callbacks?.playerDidReceiveCommonMetadata(items)
303
+ case let .chapterMetadata(groups):
304
+ callbacks?.playerDidReceiveChapterMetadata(groups)
305
+ case let .timedMetadata(groups):
306
+ callbacks?.playerDidReceiveTimedMetadata(groups)
307
+ }
308
+ }
309
+
310
+ /// `MPNowPlayingSession` publishes only through the linked player's current item, so a load
311
+ /// that fails before item creation (a dead host on a cold start) leaves nothing to surface on
312
+ /// the lock screen or CarPlay. The failed asset's item is attached purely as a publishing
313
+ /// carrier, unobserved, so its `.failed` status cannot re-report the loader's error. A
314
+ /// successful load replaces it; a retry's reload leaves it in place, so the surface doesn't
315
+ /// flicker.
316
+ private func attachPublishingCarrierItemIfNeeded() {
317
+ guard avPlayer.currentItem == nil, let asset = mediaLoader.asset else { return }
318
+ logger.notice("Attaching publishing-carrier item for failed load")
319
+ let item = AVPlayerItem(asset: asset)
320
+ nowPlayingInfoController.prepareItem(item)
321
+ avPlayer.replaceCurrentItem(with: item)
322
+ }
323
+ }
324
+
325
+ extension Player: AudioSessionPlayer {}
326
+
327
+ extension Player: NowPlayingSource {}
328
+
329
+ /// Resolves the URLs the player fetches.
330
+ @MainActor
331
+ protocol MediaResolving: AnyObject {
332
+ func resolveMediaUrl(_ src: String, track: Track?) async -> MediaResolvedUrl
333
+ func resolveNowPlayingArtwork(track: Track, imageContext: ImageContext?) async -> ImageSource?
334
+ }
@@ -0,0 +1,15 @@
1
+ import AVFoundation
2
+ import MediaPlayer
3
+ import NitroModules
4
+
5
+ /// What the AVPlayerEngine reports: the remote commands, and the metadata and now-playing
6
+ /// changes, which carry AVFoundation types. HybridAudioBrowser implements it. The method names
7
+ /// are prefixed so they don't collide with the JS callback properties.
8
+ @MainActor
9
+ protocol AVPlayerEngineCallbacks: RemoteCommandCallbacks {
10
+ func playerDidReceiveCommonMetadata(_ metadata: [AVMetadataItem])
11
+ func playerDidReceiveTimedMetadata(_ metadata: [AVTimedMetadataGroup])
12
+ func playerDidReceiveChapterMetadata(_ metadata: [AVTimedMetadataGroup])
13
+ /// The now-playing lines or track changed; carries the metadata JS sees.
14
+ func playerDidChangeNowPlaying(_ metadata: NowPlayingMetadata)
15
+ }
@@ -0,0 +1,58 @@
1
+ import NitroModules
2
+
3
+ /// The Browse Gate: an app-imposed block on browsing and searching from external surfaces,
4
+ /// decided per request. It never blocks the Player, the Queue or Now Playing.
5
+ @MainActor
6
+ final class BrowseGate {
7
+ enum Mode {
8
+ case off
9
+ /// Every request is gated, with this chrome or the built-in one.
10
+ case fixed(Gate?)
11
+ /// JS decides each request; `default` is the chrome when its decision carries none.
12
+ case resolved(default: Gate?)
13
+ }
14
+
15
+ /// Served when a request is gated with no chrome from JS or `setGate`.
16
+ static let builtInChrome = Gate(title: "Unavailable", message: nil)
17
+
18
+ private(set) var mode: Mode = .off
19
+
20
+ /// Fires `isActive` on every set and clear; chrome is per request, so surfaces re-render.
21
+ let changed = Emitter<Bool>()
22
+
23
+ /// Asks JS for a decision; set by the bridge, whose JS resolver it calls.
24
+ var resolve: @MainActor (NativeGateRequest) async throws -> GateDecision = { _ in throw CancellationError() }
25
+
26
+ nonisolated init() {}
27
+
28
+ var isActive: Bool {
29
+ if case .off = mode { false } else { true }
30
+ }
31
+
32
+ func set(chrome: Gate?, hasResolver: Bool) {
33
+ mode = hasResolver ? .resolved(default: chrome) : .fixed(chrome)
34
+ changed.emit(true)
35
+ }
36
+
37
+ func clear() {
38
+ guard isActive else { return }
39
+ mode = .off
40
+ changed.emit(false)
41
+ }
42
+
43
+ /// The chrome to show if `request` is gated; nil when it is allowed. A resolver that fails
44
+ /// cannot prove the request allowed, so it is gated: a gate exists to withhold content, and
45
+ /// serving on an error would leak it.
46
+ func decide(_ request: NativeGateRequest) async -> Gate? {
47
+ switch mode {
48
+ case .off:
49
+ return nil
50
+ case let .fixed(chrome):
51
+ return chrome ?? Self.builtInChrome
52
+ case let .resolved(defaultChrome):
53
+ guard let decision = try? await resolve(request) else { return defaultChrome ?? Self.builtInChrome }
54
+ guard decision.gated else { return nil }
55
+ return decision.gate ?? defaultChrome ?? Self.builtInChrome
56
+ }
57
+ }
58
+ }
@@ -1,97 +1,64 @@
1
1
  import Foundation
2
2
  import NitroModules
3
- import os.log
4
3
 
5
- /// Flattened browser configuration (matches Kotlin BrowserConfig).
6
- ///
7
- /// This is a convenience wrapper around NativeBrowserConfiguration that
8
- /// provides easier access to configuration properties.
4
+ /// The browser configuration, copied out of `NativeBrowserConfiguration` once per
5
+ /// `configureBrowser`: the generated getters convert from C++ on every read, and routes are read
6
+ /// on every resolve. Matches Kotlin's BrowserConfig.
9
7
  struct BrowserConfig {
10
8
  /// Base HTTP request configuration (applied to every request kind)
11
- let request: TransformableRequestConfig?
9
+ var request: TransformableRequestConfig?
12
10
 
13
11
  /// Browse-kind request configuration (applied to every browse request,
14
12
  /// layered request → browse → route)
15
- let browse: TransformableRequestConfig?
13
+ var browse: TransformableRequestConfig?
16
14
 
17
15
  /// Resolver for the shared request layer (resolved once per content generation).
18
- let requestResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
16
+ var requestResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
19
17
 
20
18
  /// Resolver for the browse layer (resolved once per content generation).
21
- let browseResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
19
+ var browseResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
22
20
 
23
21
  /// Media URL transformation configuration
24
- let media: MediaRequestConfig?
22
+ var media: MediaRequestConfig?
25
23
 
26
24
  /// Artwork URL transformation configuration
27
- let artwork: ArtworkRequestConfig?
25
+ var artwork: ArtworkRequestConfig?
28
26
 
29
27
  /// Now-playing-only artwork configuration (lock screen / CarPlay / Android Auto
30
28
  /// now-playing). A distinct kind from `artwork`; the now-playing path falls back to
31
29
  /// `artwork` when this is nil.
32
- let nowPlayingArtwork: ArtworkRequestConfig?
30
+ var nowPlayingArtwork: ArtworkRequestConfig?
33
31
 
34
32
  /// Routes as array with flattened entries
35
33
  /// Includes __tabs__, __search__, and __default__ special routes
36
- let routes: [NativeRouteEntry]?
34
+ var routes: [NativeRouteEntry]?
37
35
 
38
36
  /// Behavior: single track playback vs queue expansion
39
- let singleTrack: Bool
37
+ var singleTrack: Bool = false
40
38
 
41
39
  /// Behavior: show offline error in Android Auto controller
42
- let androidControllerOfflineError: Bool
40
+ var androidControllerOfflineError: Bool = true
43
41
 
44
42
  // MARK: - CarPlay Options
45
43
 
46
44
  /// App-localized title for CarPlay loading screens (shown as the centered
47
45
  /// empty state while content resolves; nil leaves them blank)
48
- let carPlayLoadingTitle: String?
46
+ var carPlayLoadingTitle: String?
49
47
 
50
48
  /// Resolves a browse path for the CarPlay Now Playing album line when the
51
49
  /// active track has no albumPath. Invoked on track changes (not at tap) so
52
50
  /// the album line only becomes tappable when a destination exists.
53
- let resolveAlbumPath: ((_ track: Track) -> Promise<String?>)?
51
+ var resolveAlbumPath: ((_ track: Track) -> Promise<String?>)?
54
52
 
55
53
  /// Custom handler for track load events (overrides default load behavior)
56
- let handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)?
54
+ var handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)?
57
55
 
58
56
  /// Callback to customize navigation error display (for i18n)
59
57
  /// Used by CarPlay and available via `useFormattedNavigationError()` for app UI.
60
- let formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)?
61
-
62
- init(
63
- request: TransformableRequestConfig? = nil,
64
- browse: TransformableRequestConfig? = nil,
65
- requestResolver: (() -> Promise<Promise<TransformableRequestConfig>>)? = nil,
66
- browseResolver: (() -> Promise<Promise<TransformableRequestConfig>>)? = nil,
67
- media: MediaRequestConfig? = nil,
68
- artwork: ArtworkRequestConfig? = nil,
69
- nowPlayingArtwork: ArtworkRequestConfig? = nil,
70
- routes: [NativeRouteEntry]? = nil,
71
- singleTrack: Bool = false,
72
- handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)? = nil,
73
- androidControllerOfflineError: Bool = true,
74
- carPlayLoadingTitle: String? = nil,
75
- resolveAlbumPath: ((_ track: Track) -> Promise<String?>)? = nil,
76
- formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)? = nil,
77
- ) {
78
- self.request = request
79
- self.browse = browse
80
- self.requestResolver = requestResolver
81
- self.browseResolver = browseResolver
82
- self.media = media
83
- self.artwork = artwork
84
- self.nowPlayingArtwork = nowPlayingArtwork
85
- self.routes = routes
86
- self.singleTrack = singleTrack
87
- self.handleTrackLoad = handleTrackLoad
88
- self.androidControllerOfflineError = androidControllerOfflineError
89
- self.carPlayLoadingTitle = carPlayLoadingTitle
90
- self.resolveAlbumPath = resolveAlbumPath
91
- self.formatNavigationError = formatNavigationError
92
- }
58
+ var formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)?
59
+
60
+ init() {}
93
61
 
94
- /// Create from NativeBrowserConfiguration
95
62
  init(from config: NativeBrowserConfiguration) {
96
63
  request = config.request
97
64
  browse = config.browse
@@ -117,43 +84,18 @@ struct BrowserConfig {
117
84
  /// no-content path). See `HybridAudioBrowser.dropStaleJSCallbacks` for why
118
85
  /// invoking a stale callback is fatal.
119
86
  func strippingJSCallbacks() -> BrowserConfig {
120
- BrowserConfig(
121
- request: request?.strippingJSCallbacks(),
122
- browse: browse?.strippingJSCallbacks(),
123
- media: media?.strippingJSCallbacks(),
124
- artwork: artwork?.strippingJSCallbacks(),
125
- nowPlayingArtwork: nowPlayingArtwork?.strippingJSCallbacks(),
126
- routes: routes?.map { $0.strippingJSCallbacks() },
127
- singleTrack: singleTrack,
128
- androidControllerOfflineError: androidControllerOfflineError,
129
- carPlayLoadingTitle: carPlayLoadingTitle,
130
- )
131
- }
132
-
133
- // MARK: - Track Load Handler
134
-
135
- private static let logger = Logger(subsystem: "com.audiobrowser", category: "BrowserConfig")
136
-
137
- /// Awaits the handleTrackLoad callback using the double-Promise pattern required by Nitro.
138
- ///
139
- /// Nitro wraps value-returning JS callbacks in Promise<T> for thread safety, so a JS callback
140
- /// returning Promise<void> becomes Promise<Promise<Void>> on the native side. Both layers
141
- /// must be awaited to properly wait for the JS work to complete.
142
- ///
143
- /// - Returns: true if the handler was present and invoked, false if not configured
144
- func awaitTrackLoadHandler(event: TrackLoadEvent) async -> Bool {
145
- guard let handler = handleTrackLoad else { return false }
146
- do {
147
- // MainActor: Nitro bridge call must be on main thread (C++ noexcept)
148
- nonisolated(unsafe) let _handler = handler
149
- nonisolated(unsafe) let _event = event
150
- let outerPromise = await MainActor.run { _handler(_event) }
151
- let innerPromise = try await outerPromise.await()
152
- try await innerPromise.await()
153
- } catch {
154
- Self.logger.error("handleTrackLoad failed: \(error.localizedDescription)")
155
- }
156
- return true
87
+ // Built up from empty, so a field added later is dropped unless it is copied here.
88
+ var stripped = BrowserConfig()
89
+ stripped.request = request?.strippingJSCallbacks()
90
+ stripped.browse = browse?.strippingJSCallbacks()
91
+ stripped.media = media?.strippingJSCallbacks()
92
+ stripped.artwork = artwork?.strippingJSCallbacks()
93
+ stripped.nowPlayingArtwork = nowPlayingArtwork?.strippingJSCallbacks()
94
+ stripped.routes = routes?.map { $0.strippingJSCallbacks() }
95
+ stripped.singleTrack = singleTrack
96
+ stripped.androidControllerOfflineError = androidControllerOfflineError
97
+ stripped.carPlayLoadingTitle = carPlayLoadingTitle
98
+ return stripped
157
99
  }
158
100
  }
159
101