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
@@ -5,7 +5,7 @@ import MediaPlayer
5
5
  import NitroModules
6
6
  import os.log
7
7
 
8
- @MainActor let playerAndConfiguredBrowser = OnceValue<(HybridAudioBrowser, TrackPlayer)> {
8
+ @MainActor let playerAndConfiguredBrowser = ReadyValue<(HybridAudioBrowser, Player)> {
9
9
  guard let browser = HybridAudioBrowser.shared,
10
10
  browser.browserManager.isConfigured,
11
11
  let player = browser.getPlayer() else { return nil }
@@ -21,7 +21,7 @@ private struct UncheckedSendableBox<T>: @unchecked Sendable { let value: T }
21
21
  /// can't be `@MainActor`. Isolation is therefore per-property, and `@unchecked`
22
22
  /// means the compiler won't tell you when a new one needs it.
23
23
  public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
24
- private let logger = Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
24
+ let logger = Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
25
25
 
26
26
  // MARK: - Shared Instance for CarPlay
27
27
 
@@ -56,30 +56,20 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
56
56
 
57
57
  /// Main-confined by convention, not annotation: `deinit` reads it as a
58
58
  /// safety net and can't hop. Reach it from `onMainActor` / `@MainActor`.
59
- private var player: TrackPlayer?
60
- private let networkMonitor = NetworkMonitor()
61
- private let playbackStateStore = PlaybackStateStore()
59
+ var player: Player?
60
+ /// The media stack under `player`, for what only AVFoundation has: now playing, buffering,
61
+ /// volume and the remote commands.
62
+ var engine: AVPlayerEngine?
63
+ let networkMonitor = NetworkMonitor()
62
64
  let browserManager = BrowserManager()
63
- private let trackSelector: TrackSelector
64
- private var volumeObservation: NSKeyValueObservation?
65
- private var routeChangeObserver: NSObjectProtocol?
66
- private var interruptionObserver: NSObjectProtocol?
67
- private var mediaServicesResetObserver: NSObjectProtocol?
68
- /// Re-applies our resolved audio-session category. Captured at setup so a
69
- /// media-services reset (which clears the category) can restore it.
70
- @MainActor private var applyAudioSessionCategory: () -> Void = {}
71
- @MainActor private var nowPlayingOverride: NowPlayingUpdate?
72
- /// When false, the now-playing surface uses the raw track fields (override + formatter ignored).
73
- @MainActor private var nowPlayingMetadataEnabled = true
74
- /// Customizes the now-playing title/subtitle from the track + live timed metadata + playback state.
75
- @MainActor private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
65
+ let browseGate = BrowseGate()
66
+ let trackSelector: TrackSelector
67
+ @MainActor private(set) lazy var audioSession = AudioSessionController()
76
68
  /// Initial player state staged before the player exists — from setup options or the imperative
77
69
  /// setters called pre-setup. Strict last-write-wins; consumed when the player comes up.
78
- @MainActor private var pendingPlayWhenReady: Bool?
79
- @MainActor private var pendingRepeatMode: RepeatMode?
80
- /// Latest live timed (ICY/ID3) metadata, passed to the formatter. Cleared on track change.
81
- @MainActor private var latestTimedMetadata: TimedMetadata?
82
- @MainActor private let playerOptions = PlayerUpdateOptions()
70
+ @MainActor var pendingPlayWhenReady: Bool?
71
+ @MainActor var pendingRepeatMode: RepeatMode?
72
+ @MainActor let playerOptions = PlayerUpdateOptions()
83
73
 
84
74
  /// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
85
75
  @MainActor var playbackRates: [Double] { playerOptions.playbackRates }
@@ -92,31 +82,14 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
92
82
  playerOptions.carPlayPlayingIndicatorLocation
93
83
  }
94
84
 
95
- /// Written from the JS thread, the cooperative pool and MainActor, with a
96
- /// `didSet` that calls back into Nitro. MainActor-isolated so the compiler
97
- /// requires an `onMainActor` hop at every access.
98
- @MainActor private var lastNavigationError: NavigationError? {
99
- didSet {
100
- // Skip if both nil (no real change)
101
- guard oldValue != nil || lastNavigationError != nil else { return }
102
- navigationErrorEmitter.emit(NavigationErrorEvent(error: lastNavigationError))
103
- }
104
- }
105
-
106
- /// Isolated for the same reason as ``lastNavigationError``.
107
- @MainActor private var lastFormattedNavigationError: FormattedNavigationError? {
108
- didSet {
109
- // Skip if both nil (no real change)
110
- guard oldValue != nil || lastFormattedNavigationError != nil else { return }
111
- onFormattedNavigationError(lastFormattedNavigationError)
112
- }
113
- }
85
+ @MainActor lazy var navigationError = NavigationErrorState(
86
+ onErrorChanged: { [weak self] in self?.navigationErrorEmitter.emit(NavigationErrorEvent(error: $0)) },
87
+ onFormattedChanged: { [weak self] in self?.onFormattedNavigationError($0) },
88
+ )
114
89
 
115
90
  // MARK: - Multi-Listener Emitters
116
91
 
117
92
  /// Emitters allow multiple listeners for each event type, avoiding callback hijacking
118
- public let tabsChangedEmitter = Emitter<[Track]>()
119
- public let contentChangedEmitter = Emitter<ResolvedTrack?>()
120
93
  public let favoriteChangedEmitter = Emitter<FavoriteChangedEvent>()
121
94
  public let activeTrackChangedEmitter = Emitter<PlaybackActiveTrackChangedEvent>()
122
95
  public let queueChangedEmitter = Emitter<[Track]>()
@@ -124,11 +97,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
124
97
  public let repeatModeChangedEmitter = Emitter<RepeatModeChangedEvent>()
125
98
  public let shuffleChangedEmitter = Emitter<Bool>()
126
99
  public let externalContentChangedEmitter = Emitter<String>()
127
- /// Signals that the gate's active state changed (set or cleared). Carries
128
- /// the active flag; per-request chrome is obtained at each serve site via
129
- /// `gateDecision(for:)`, so the emitter only tells surfaces to re-render.
130
- public let gateChangedEmitter = Emitter<Bool>()
131
- /// Fired when a voice media intent (`handlePlayMediaIntent`) successfully
100
+ /// Fired when a voice media intent (`MediaIntentPlayback`) successfully
132
101
  /// starts playback. CarPlay surfaces the Now Playing template in response, so
133
102
  /// the user lands on the playing station (with the rest of the results in Up
134
103
  /// Next) rather than the screen they invoked Siri from. No-op where unobserved.
@@ -142,7 +111,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
142
111
 
143
112
  /// Runs a closure on MainActor synchronously. Safe for non-Sendable return types
144
113
  /// because DispatchQueue.main.sync blocks the caller — no concurrent access window.
145
- private func onMainActor<T>(_ work: @MainActor () -> T) -> T {
114
+ func onMainActor<T>(_ work: @MainActor () -> T) -> T {
146
115
  if Thread.isMainThread {
147
116
  MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
148
117
  } else {
@@ -152,7 +121,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
152
121
  }
153
122
  }
154
123
 
155
- private func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
124
+ func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
156
125
  if Thread.isMainThread {
157
126
  try MainActor.assumeIsolated { try UncheckedSendableBox(value: work()) }.value
158
127
  } else {
@@ -165,21 +134,15 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
165
134
  // MARK: - Browser Properties
166
135
 
167
136
  public var path: String? {
168
- get { onMainActor { browserManager.getPath() } }
137
+ get { onMainActor { browserManager.path } }
169
138
  set {
170
139
  guard let newPath = newValue else { return }
171
- Task {
172
- do {
173
- try await browserManager.navigate(newPath)
174
- } catch {
175
- handleNavigationError(error, path: newPath)
176
- }
177
- }
140
+ navigate(newPath, clearError: false)
178
141
  }
179
142
  }
180
143
 
181
144
  public var tabs: [Track]? {
182
- get { onMainActor { browserManager.getTabs() } }
145
+ get { onMainActor { browserManager.tabs } }
183
146
  set { /* tabs are managed internally by browserManager */ }
184
147
  }
185
148
 
@@ -204,14 +167,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
204
167
  Task {
205
168
  let tabs = try? await browserManager.queryTabs()
206
169
  // Navigate to configured path, first tab, or "/"
207
- let initialPath = configuredPath ?? tabs?.first?.path ?? "/"
208
- // Clear error before navigation (matches Kotlin clearNavigationError())
209
- clearNavigationError()
210
- do {
211
- try await browserManager.navigate(initialPath)
212
- } catch {
213
- handleNavigationError(error, path: initialPath)
214
- }
170
+ navigate(configuredPath ?? tabs?.first?.path ?? "/", clearError: true)
215
171
  }
216
172
  }
217
173
  }
@@ -231,11 +187,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
231
187
  /// JS-side Promise, the inner to the decision.
232
188
  ///
233
189
  /// Default fails CLOSED (`gated: true`). This default is only consulted in the
234
- /// init window where `hasResolver` was already recorded true but JS hasn't
190
+ /// init window where the gate is already `.resolved` but JS hasn't
235
191
  /// (re)bound the real resolver yet (JS reload / instance churn re-seeding gate
236
- /// state, e.g. CarPlay re-seeding `isGated` at scene connect). Serving content
192
+ /// state, e.g. CarPlay deciding its tabs at scene connect). Serving content
237
193
  /// in that window would be a fail-open leak — same direction as the
238
- /// resolver-error path in `gateDecision`. Matches Android's default.
194
+ /// resolver-error path in `BrowseGate.decide`. Matches Android's default.
239
195
  public var resolveGate: (NativeGateRequest) -> Promise<Promise<GateDecision>> = HybridAudioBrowser.defaultResolveGate
240
196
 
241
197
  // Computed, not stored: a nonisolated static holding a non-Sendable closure
@@ -312,36 +268,44 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
312
268
  public var onEqualizerChanged: (EqualizerSettings) -> Void = { _ in }
313
269
  public var onBatteryWarningPendingChanged: (BatteryWarningPendingChangedEvent) -> Void = { _ in }
314
270
  public var onBatteryOptimizationStatusChanged: (BatteryOptimizationStatusChangedEvent) -> Void = { _ in }
315
- public var onSystemVolumeChanged: (Double) -> Void = { _ in } {
316
- didSet {
317
- setupVolumeObserver()
318
- }
319
- }
271
+ public var onSystemVolumeChanged: (Double) -> Void = { _ in }
320
272
 
321
- public var onOutputChanged: (Output) -> Void = { _ in } {
273
+ public var onOutputChanged: (Output) -> Void = { _ in }
274
+
275
+ public var onCarConnectedChanged: (Bool) -> Void = { _ in } {
322
276
  didSet {
323
- setupRouteChangeObserver()
277
+ // Immediately notify current state (the scene may have connected before
278
+ // this JS runtime subscribed).
279
+ onCarConnectedChanged(HybridAudioBrowser.carPlayConnected)
324
280
  }
325
281
  }
326
282
 
283
+ /// Stored so a re-setup can re-apply it: unlike the other runtime options this
284
+ /// one has no home in `playerOptions`, and the player it was pushed to does not
285
+ /// survive `setupPlayer`.
286
+ @MainActor var playbackIntervalEnabled = false
287
+
327
288
  // MARK: - Initialization
328
289
 
329
290
  override public init() {
330
291
  trackSelector = TrackSelector(browserManager: browserManager)
331
292
  super.init()
332
293
 
333
- // Clean up the previous instance (e.g., on JS runtime reload).
334
- // The old HybridAudioBrowser may still be retained by the global
335
- // playerAndConfiguredBrowser OnceValue, so we must explicitly stop
336
- // its player to prevent two audio streams running simultaneously.
294
+ // Clean up the previous instance (e.g., on JS runtime reload): stop its
295
+ // player so two audio streams never run at once.
337
296
  onMainActor {
338
297
  // Statics are lazy — touch the observer so it's installed from the first
339
298
  // instance onward.
340
299
  _ = HybridAudioBrowser.reloadObserver
341
- HybridAudioBrowser.shared?.player?.destroy()
300
+ HybridAudioBrowser.shared?.destroyPlayer()
342
301
  HybridAudioBrowser.shared?.dropStaleJSCallbacks()
343
- playerAndConfiguredBrowser.reset()
344
302
  HybridAudioBrowser.shared = self
303
+ audioSession.outputChanged.addListener { [weak self] in self?.onOutputChanged($0) }
304
+ audioSession.volumeChanged.addListener { [weak self] in self?.onSystemVolumeChanged($0) }
305
+ browseGate.resolve = { [weak self] request in
306
+ guard let self else { throw CancellationError() }
307
+ return try await resolveGate(request).await().await()
308
+ }
345
309
  // Tell connected external controllers (CarPlay) to re-subscribe against
346
310
  // this instance. Unconditional: `shared` is weak, so on a JS reload the
347
311
  // old instance may already be gone — "shared was nil" does not mean no
@@ -349,7 +313,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
349
313
  HybridAudioBrowser.instanceChangedEmitter.emit(())
350
314
  }
351
315
  setupEmitterToNitroForwarding()
352
- setupBrowserCallbacks()
353
316
  }
354
317
 
355
318
  /// Drops every JS-provided callback that native code can invoke on its own
@@ -365,7 +328,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
365
328
  /// reverts to the fail-closed default it documents for exactly this window.
366
329
  /// One-shot: on a normal reload both triggers hit the same dying instance
367
330
  /// (the reload notification, then the replacement's `init`), and the second
368
- /// strip would re-fire the config-setter cascade — another `onConfigChanged`
331
+ /// strip would re-fire the config-setter cascade — another `configChanged`
369
332
  /// → CarPlay now-playing button rebuild — for nothing.
370
333
  @MainActor private var didDropJSCallbacks = false
371
334
 
@@ -381,46 +344,45 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
381
344
  browserManager.config = browserManager.config.strippingJSCallbacks()
382
345
  }
383
346
  resolveGate = Self.defaultResolveGate
384
- nowPlayingMetadataFormatter = nil
347
+ engine?.nowPlayingUpdater.formatter = nil
385
348
  }
386
349
 
387
350
  deinit {
388
- // Block-based notification observers are retained by the center until an
389
- // explicit removeObserver — observer death isn't detected — so without this
390
- // every JS reload strands another token + closure. They capture `self`
391
- // weakly, so a stranded one no-ops rather than misbehaving; what accumulates
392
- // is the tokens and a wasted main-queue dispatch per interruption / route
393
- // change / media-services reset. (`volumeObservation` needs no counterpart:
394
- // NSKeyValueObservation invalidates itself when this instance releases it.)
395
- for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
396
- .compactMap(\.self)
397
- {
398
- NotificationCenter.default.removeObserver(observer)
399
- }
400
-
401
351
  // Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
402
- let player = self.player
352
+ let player = self.player, engine = self.engine
403
353
  if Thread.isMainThread {
404
- MainActor.assumeIsolated { player?.destroy() }
354
+ MainActor.assumeIsolated {
355
+ player?.clear()
356
+ engine?.destroy()
357
+ }
405
358
  } else {
406
- DispatchQueue.main.async { MainActor.assumeIsolated { player?.destroy() } }
359
+ DispatchQueue.main.async {
360
+ MainActor.assumeIsolated {
361
+ player?.clear()
362
+ engine?.destroy()
363
+ }
364
+ }
407
365
  }
408
366
  }
409
367
 
410
- /// Returns the TrackPlayer instance, if setup has been called.
411
- func getPlayer() -> TrackPlayer? {
368
+ /// Returns the player, if setup has been called.
369
+ func getPlayer() -> Player? {
412
370
  player
413
371
  }
414
372
 
373
+ /// Stops the player and releases its media stack: the AVPlayer and the now-playing session
374
+ /// outlive a dropped reference otherwise.
375
+ @MainActor
376
+ func destroyPlayer() {
377
+ player?.clear()
378
+ engine?.destroy()
379
+ }
380
+
415
381
  /// Sets up automatic forwarding from emitters to Nitro callbacks
416
382
  private func setupEmitterToNitroForwarding() {
417
- tabsChangedEmitter.addListener { [weak self] tabs in
418
- self?.onTabsChanged(tabs)
419
- }
420
-
421
- contentChangedEmitter.addListener { [weak self] content in
422
- self?.onContentChanged(content)
423
- }
383
+ browserManager.pathChanged.addListener { [weak self] in self?.onPathChanged($0) }
384
+ browserManager.tabsChanged.addListener { [weak self] in self?.onTabsChanged($0) }
385
+ browserManager.contentChanged.addListener { [weak self] in self?.onContentChanged($0) }
424
386
 
425
387
  favoriteChangedEmitter.addListener { [weak self] event in
426
388
  self?.onFavoriteChanged(event)
@@ -442,1581 +404,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
442
404
  self?.onPlaybackRepeatModeChanged(event)
443
405
  }
444
406
  }
445
-
446
- private func setupBrowserCallbacks() {
447
- onMainActor {
448
- browserManager.onPathChanged = { [weak self] path in
449
- self?.onPathChanged(path)
450
- }
451
- browserManager.onContentChanged = { [weak self] content in
452
- self?.contentChangedEmitter.emit(content)
453
- }
454
- browserManager.onTabsChanged = { [weak self] tabs in
455
- self?.tabsChangedEmitter.emit(tabs)
456
- }
457
- }
458
- }
459
-
460
- private func handleNavigationError(_ error: Error, path: String) {
461
- let navError = NavigationError.from(error)
462
-
463
- onMainActor { lastNavigationError = navError }
464
-
465
- // Format the error (async if using JS callback, sync for defaults)
466
- let defaultFormatted = navError.defaultFormatted()
467
- if let formatter = onMainActor({ browserManager.config.formatNavigationError }) {
468
- let params = FormatNavigationErrorParams(
469
- error: navError, defaultFormatted: defaultFormatted, path: path,
470
- kind: NavigationErrorKind(path: path),
471
- )
472
- // Dispatch to main thread for the Nitro bridge call to avoid C++ noexcept crashes.
473
- // The result callbacks are @Sendable: a plain closure would inherit MainActor
474
- // isolation from this block, and Nitro resolves its promises synchronously on the
475
- // JS thread — Swift 6.2's dynamic isolation check then traps on closure entry.
476
- // They therefore hop back explicitly, like every other writer.
477
- DispatchQueue.main.async { [weak self] in
478
- formatter(params)
479
- .then { @Sendable [weak self] customDisplay in
480
- self?.setFormattedNavigationError(customDisplay ?? defaultFormatted)
481
- }
482
- .catch { @Sendable [weak self] _ in
483
- self?.setFormattedNavigationError(defaultFormatted)
484
- }
485
- }
486
- } else {
487
- setFormattedNavigationError(defaultFormatted)
488
- }
489
- }
490
-
491
- /// Callable from any thread — the Nitro promise callbacks below resolve on
492
- /// the JS thread.
493
- private func setFormattedNavigationError(_ formatted: FormattedNavigationError?) {
494
- onMainActor { lastFormattedNavigationError = formatted }
495
- }
496
-
497
- /// Synchronous on purpose: the `didSet`s notify JS, which has to happen
498
- /// before the navigation that may set them again.
499
- private func clearNavigationError() {
500
- onMainActor {
501
- lastNavigationError = nil
502
- lastFormattedNavigationError = nil
503
- }
504
- }
505
-
506
- // MARK: - Browser Methods
507
-
508
- public func navigatePath(path: String) throws {
509
- clearNavigationError()
510
- Task {
511
- do {
512
- try await browserManager.navigate(path)
513
- } catch {
514
- handleNavigationError(error, path: path)
515
- }
516
- }
517
- }
518
-
519
- public func navigateTrack(track: Track) throws {
520
- // @MainActor: `player` is main-confined, and `trackSelector.select` is
521
- // MainActor anyway — a bare Task read the reference off the cooperative
522
- // pool, racing the assignment in `setupPlayer`.
523
- Task { @MainActor in
524
- guard let player else { return }
525
- let result = await trackSelector.select(track: track, player: player)
526
- switch result {
527
- case let .play(intent):
528
- executePlayback(intent)
529
- case .intercepted:
530
- break
531
- case let .browse(path):
532
- navigateToBrowsablePath(path)
533
- case .none:
534
- break
535
- }
536
- }
537
- }
538
-
539
- private func executePlayback(_ intent: TrackSelector.PlaybackIntent) {
540
- onMainActor {
541
- switch intent {
542
- case let .skipTo(index):
543
- do {
544
- try player?.skipTo(index, playWhenReady: true)
545
- } catch {
546
- logger.error("Failed to skip to track at index \(index): \(error)")
547
- }
548
- case let .setQueue(tracks, startIndex, sourcePath):
549
- player?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: sourcePath)
550
- case let .loadTrack(track):
551
- player?.load(track, playWhenReady: true)
552
- }
553
- }
554
- }
555
-
556
- private func navigateToBrowsablePath(_ path: String) {
557
- clearNavigationError()
558
- Task {
559
- do {
560
- try await browserManager.navigate(path)
561
- } catch {
562
- handleNavigationError(error, path: path)
563
- }
564
- }
565
- }
566
-
567
- public func search(query: String) throws -> Promise<[Track]> {
568
- Promise.async { [weak self] in
569
- guard let self else { return [] }
570
- let resolved = try await browserManager.search(query)
571
- return resolved.flattenedChildren ?? []
572
- }
573
- }
574
-
575
- public func getContent() throws -> ResolvedTrack? {
576
- onMainActor { browserManager.getContent() }
577
- }
578
-
579
- public func getNavigationError() throws -> NavigationError? {
580
- onMainActor { lastNavigationError }
581
- }
582
-
583
- public func getFormattedNavigationError() throws -> FormattedNavigationError? {
584
- onMainActor { lastFormattedNavigationError }
585
- }
586
-
587
- /// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
588
- /// refresh every displayed template. Not a real path (paths are always
589
- /// `/…`), so it can't collide with one; kept internal — the public API is
590
- /// `invalidateAllContent()`.
591
- static let invalidateAllSentinel = "__rnab_invalidate_all__"
592
-
593
- public func notifyContentChanged(path: String) throws {
594
- // Throws on a contextual path (matching Android's `require`) — the caller
595
- // must pass the container path, or the invalidation silently hits nothing.
596
- try onMainActor { try browserManager.invalidateContentCache(path) }
597
-
598
- // Notify external controllers (CarPlay) that content changed
599
- externalContentChangedEmitter.emit(path)
600
-
601
- // Re-resolve the path if it's the current browser path
602
- if onMainActor({ browserManager.getPath() }) == path {
603
- Task {
604
- do {
605
- try await browserManager.navigate(path)
606
- } catch {
607
- handleNavigationError(error, path: path)
608
- }
609
- }
610
- }
611
- }
612
-
613
- public func invalidateAllContent() throws {
614
- onMainActor { browserManager.clearContentCache() }
615
-
616
- // Tell external controllers (CarPlay) to refresh every displayed template.
617
- externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
618
-
619
- // Re-query the tabs source too: a callback source can resolve to new
620
- // titles after an invalidation (e.g. a locale switch). BrowserManager
621
- // emits tabsChanged only when the result actually differs, and a failed
622
- // re-query keeps the current tabs. (Android needs no equivalent — its
623
- // root re-query already runs queryTabs(); the web stub re-queries tabs on
624
- // the re-navigation.)
625
- Task {
626
- _ = try? await browserManager.queryTabs()
627
- }
628
-
629
- // Re-resolve the JS-facing current path with the cache cleared.
630
- Task {
631
- do {
632
- try await browserManager.refresh()
633
- } catch {
634
- handleNavigationError(error, path: onMainActor { browserManager.getPath() })
635
- }
636
- }
637
- }
638
-
639
- public func setFavorites(favorites: [String]) throws {
640
- onMainActor { browserManager.setFavorites(favorites) }
641
- }
642
-
643
- // MARK: - Gate
644
-
645
- /// The gate's default chrome, if one was set. Served when the resolver
646
- /// returns a gated decision with no per-request override, and for the static
647
- /// gate (no resolver). May be nil for a resolver-only gate.
648
- private(set) var defaultChrome: Gate?
649
-
650
- /// Whether JS installed a per-request resolver. When false the gate is static
651
- /// — every request is gated with `defaultChrome` and the JS hop is skipped.
652
- private(set) var hasResolver = false
653
-
654
- /// True once `setGate` runs, until `clearGate`. While active, external
655
- /// surfaces (CarPlay) keep their tabs visible but consult `gateDecision(for:)`
656
- /// per request, rendering the gate chrome where gated and refusing
657
- /// external-surface search. Playback, the queue and Now Playing are
658
- /// unaffected — a gate blocks finding content, never hearing it.
659
- private(set) var isGateActive = false
660
-
661
- /// The minimal built-in gate served when a request is gated but neither a
662
- /// per-request override nor a stored default chrome exists (resolver-only
663
- /// gate returning `true`).
664
- static let builtInGate = Gate(title: "Unavailable", message: nil)
665
-
666
- /// The outcome of a single gate decision: whether to gate, and (if so) the
667
- /// chrome to render.
668
- struct GateOutcome {
669
- let gated: Bool
670
- let chrome: Gate?
671
- }
672
-
673
- public func setGate(gate: Gate?, hasResolver: Bool) throws {
674
- onMainActor {
675
- defaultChrome = gate
676
- self.hasResolver = hasResolver
677
- isGateActive = true
678
- gateChangedEmitter.emit(true)
679
- }
680
- }
681
-
682
- public func clearGate() throws {
683
- onMainActor {
684
- guard isGateActive else { return }
685
- defaultChrome = nil
686
- hasResolver = false
687
- isGateActive = false
688
- gateChangedEmitter.emit(false)
689
- }
690
- }
691
-
692
- /// The single choke point for every gate enforcement site. Asks whether
693
- /// `request` should be gated and, if so, with what chrome (override → stored
694
- /// default → built-in). When no resolver is installed the gate is static and
695
- /// resolves without a JS hop.
696
- ///
697
- /// Resolver errors **fail CLOSED by design**: a thrown/rejected resolver, or a
698
- /// rejection at either `.await()` layer (bridge tear-down on JS reload, the JS
699
- /// runtime mid-reload, a serialization error) cannot prove the request is
700
- /// allowed — and a gate exists to withhold content, so serving on error would
701
- /// be a content leak (a paywall bypass). On error we gate with the stored
702
- /// default / built-in chrome so the user still sees a coherent gate page. A
703
- /// *successful* resolver returning `gated: false` still allows the request.
704
- @MainActor
705
- func gateDecision(for request: NativeGateRequest) async -> GateOutcome {
706
- guard isGateActive else { return GateOutcome(gated: false, chrome: nil) }
707
- if !hasResolver { return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate) }
708
- guard let decision = try? await resolveGate(request).await().await() else {
709
- // Resolver error: fail CLOSED (see doc comment) — gate with the default /
710
- // built-in so the gate page still renders rather than leaking content.
711
- return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate)
712
- }
713
- guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
714
- return GateOutcome(gated: true, chrome: decision.gate ?? defaultChrome ?? Self.builtInGate)
715
- }
716
-
717
- // MARK: - Car Connection (CarPlay)
718
-
719
- /// Static because the CarPlay scene can connect before the JS runtime
720
- /// creates the shared instance (cold start in the car).
721
- private nonisolated(unsafe) static var carPlayConnected = false
722
-
723
- /// Called by the CarPlay scene delegate (via `RNABCarPlayController`) on
724
- /// scene connect/disconnect — not from controller start/stop, which also
725
- /// cycle on a JS reload and would emit spurious connection changes.
726
- static func setCarPlayConnected(_ connected: Bool) {
727
- guard carPlayConnected != connected else { return }
728
- carPlayConnected = connected
729
- shared?.onCarConnectedChanged(connected)
730
- }
731
-
732
- public func isCarConnected() throws -> Bool {
733
- HybridAudioBrowser.carPlayConnected
734
- }
735
-
736
- public var onCarConnectedChanged: (Bool) -> Void = { _ in } {
737
- didSet {
738
- // Immediately notify current state (the scene may have connected before
739
- // this JS runtime subscribed).
740
- onCarConnectedChanged(HybridAudioBrowser.carPlayConnected)
741
- }
742
- }
743
-
744
- // MARK: - Player Setup
745
-
746
- public func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
747
- Promise.async {
748
- // Configure the audio session from the iOS setup options (category, mode, route policy, and
749
- // options like `allowBluetooth` / `allowAirPlay`), but do NOT activate it here. Activating a
750
- // non-mixable `.playback` session interrupts other apps' audio — so activating at setup
751
- // pauses whatever the user is listening to (Safari, Spotify, a podcast) the instant our app
752
- // launches, before they play anything. Setting the category is silent; only activation
753
- // interrupts. The session is activated lazily when playback actually starts producing output
754
- // (see `playerDidChangePlayingState`) and re-activated on interruption-end. Best-effort: a
755
- // category-config failure must never brick setup.
756
- let session = AVAudioSession.sharedInstance()
757
- let cfg: (
758
- category: AVAudioSession.Category,
759
- mode: AVAudioSession.Mode,
760
- policy: AVAudioSession.RouteSharingPolicy,
761
- options: AVAudioSession.CategoryOptions,
762
- ) = options.ios?.resolveAudioSessionConfig()
763
- ?? (category: .playback, mode: .default, policy: .default, options: [])
764
- // Capture category application once so a media-services reset (which
765
- // clears the category) can restore the exact same config later.
766
- let applyCategory: () -> Void = {
767
- try? session.setCategory(cfg.category, mode: cfg.mode, policy: cfg.policy, options: cfg.options)
768
- }
769
- applyCategory()
770
-
771
- // Create player and configure on main actor
772
- await MainActor.run { [weak self] in
773
- guard let self else { return }
774
- self.applyAudioSessionCategory = applyCategory
775
-
776
- // A re-setup REPLACES the player, so the outgoing one has to be torn down
777
- // and un-published first. `TrackPlayer` has no deinit: dropping the
778
- // reference alone would leave its AVPlayer and MPNowPlayingSession alive.
779
- // And `playerAndConfiguredBrowser` holds the old player strongly — its
780
- // `resolve` is a one-shot, so without the reset the gate would keep
781
- // handing the dead player to the CarPlay controller and Siri intents,
782
- // which both await it. No-op on first setup.
783
- player?.destroy()
784
- playerAndConfiguredBrowser.reset()
785
-
786
- // Create player with self as callbacks delegate
787
- player = TrackPlayer(callbacks: self)
788
-
789
- // Apply buffer duration from options (in ms)
790
- if let buffer = options.ios?.buffer {
791
- player?.bufferDuration = buffer
792
- }
793
-
794
- // Apply retry configuration (top-level, cross-platform)
795
- if let retry = options.retry {
796
- player?.retryConfig = retry
797
- }
798
-
799
- // Wire up network monitor for accelerated retries when connectivity is restored
800
- player?.networkMonitor = networkMonitor
801
-
802
- // Central fan-out for connectivity changes (wired once here, idempotent on re-setup, and
803
- // independent of any JS subscription):
804
- // - bridge to the JS `onOnlineChanged` event (no-op default until JS subscribes);
805
- // - re-render now-playing so the formatter's offline/online label updates immediately on a
806
- // connectivity change, not only on the next playback transition (fixes the stall-entry
807
- // race where the device goes offline after the buffering line was already rendered);
808
- // - reconnect a stalled stream when connectivity returns (see TrackPlayer.handleNetworkRestored).
809
- // The monitor only mutates state on the main thread, so this fires main-isolated.
810
- networkMonitor.onChanged = { [weak self] isOnline in
811
- MainActor.assumeIsolated {
812
- guard let self else { return }
813
- self.onOnlineChanged(isOnline)
814
- self.applyNowPlayingMetadata()
815
- if isOnline { self.player?.handleNetworkRestored() }
816
- }
817
- }
818
-
819
- // Observe audio-session interruptions so another app taking over audio
820
- // (or a phone call) reflects as a real pause rather than a stuck state.
821
- setupInterruptionObserver()
822
-
823
- // Observe media-server resets so a long-running stream recovers instead
824
- // of going permanently silent.
825
- setupMediaServicesResetObserver()
826
-
827
- // Configure media URL resolver
828
- player?.mediaLoader.mediaUrlResolver = { [weak self] src, track in
829
- guard let self else {
830
- return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
831
- }
832
- return await browserManager.resolveMediaUrl(src, track: track)
833
- }
834
-
835
- // Configure artwork URL resolver for Now Playing (with size context). The
836
- // `nowPlayingArtwork` config runs for EVERY playing track, id or not — an id-less track
837
- // leaves any `{id}` token unfilled (resolveArtworkUrl warns and the request visibly
838
- // 404s) rather than skipping the config.
839
- player?.nowPlayingUpdater.artworkUrlResolver = { [weak self] track, imageContext in
840
- guard let self else { return nil }
841
- return await browserManager.resolveArtworkUrl(
842
- track: track, perRouteConfig: browserManager.config.nowPlayingArtwork,
843
- imageContext: imageContext,
844
- )
845
- }
846
-
847
- // Emit the JS `onNowPlayingChanged` event when the rendered text lines change. The updater
848
- // owns the system now-playing write; this shapes the JS metadata (elapsed time / artwork).
849
- player?.nowPlayingUpdater.onChanged = { [weak self] track, title, artist, album in
850
- guard let self else { return }
851
- self.onNowPlayingChanged(
852
- self.makeNowPlayingMetadata(track: track, title: title, artist: artist, album: album))
853
- }
854
-
855
- // Configure sleep timer callback
856
- player?.sleepTimerManager.onChanged = { [weak self] state in
857
- self?.onSleepTimerChanged(state)
858
- }
859
-
860
- // Now-playing: whether to manage metadata, and the optional formatter that owns the
861
- // rendered lines.
862
- nowPlayingMetadataEnabled = options.autoUpdateNowPlayingMetadata ?? true
863
- nowPlayingMetadataFormatter = options.nowPlayingMetadataFormatter
864
- // `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
865
- // A terminal error resolves the playback state to *paused*
866
- // (PlaybackCoordinator.applySideEffects), the now-playing info is retained (never cleared
867
- // on error), and next/previous stay enabled (gated on queue position, not state). So the
868
- // session stays controllable through errors by default — the behavior Android achieves via
869
- // InterceptingPlayer is already the iOS norm.
870
-
871
- // The bundled runtime options and initial state are part of the atomic launch
872
- // description. Stage the state first so setup options and earlier pre-setup setter
873
- // calls resolve by strict last-write-wins, then apply everything to the live player.
874
- if let bundled = options.options {
875
- updateOptions(options: bundled)
876
- } else {
877
- // updateOptions applies remote commands itself; without bundled options, apply the
878
- // defaults (play, pause, next, previous, seekTo) here.
879
- applyRemoteCommands()
880
- }
881
- if let mode = options.repeatMode { pendingRepeatMode = mode }
882
- if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
883
- applyPendingPlayerState()
884
-
885
- // Push the resolved event cadence unconditionally. `updateOptions` applies
886
- // the interval only when it *differs* from the stored one, but the store
887
- // outlives the player — so on a re-setup with unchanged options the new
888
- // player would come up with no progress timer at all.
889
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
890
- player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
891
-
892
- // Notify listeners that player is ready (e.g., CarPlay)
893
- playerAndConfiguredBrowser.check()
894
- }
895
- }
896
- }
897
-
898
- /// Applies initial player state staged before the player existed — from setup options or the
899
- /// imperative setters called pre-setup. Consumed on apply so a later re-setup doesn't replay
900
- /// stale state.
901
- @MainActor
902
- private func applyPendingPlayerState() {
903
- guard let player else { return }
904
- if let mode = pendingRepeatMode {
905
- player.repeatMode = mode
906
- pendingRepeatMode = nil
907
- }
908
- if let intent = pendingPlayWhenReady {
909
- player.playWhenReady = intent
910
- pendingPlayWhenReady = nil
911
- }
912
- }
913
-
914
- public func updateOptions(options: NativeUpdateOptions) {
915
- onMainActor {
916
- let previousInterval = playerOptions.progressUpdateEventInterval
917
-
918
- // Update stored options
919
- playerOptions.update(from: options)
920
-
921
- // Propagate the favorite flag to the browser so it can hydrate
922
- // row hearts (the `favorite` capability is the single favoriting switch).
923
- browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
924
-
925
- // Apply remote commands to player
926
- applyRemoteCommands()
927
-
928
- // Apply progress update interval if changed
929
- if playerOptions.progressUpdateEventInterval != previousInterval {
930
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
931
- }
932
-
933
- onOptionsChanged(playerOptions.toOptions())
934
- playerOptionsChangedEmitter.emit(())
935
- }
936
- }
937
-
938
- /// Converts capabilities to remote commands and applies them to the player
939
- @MainActor
940
- private func applyRemoteCommands() {
941
- guard let player else { return }
942
-
943
- let remoteCommands = playerOptions.capabilities.buildRemoteCommands(
944
- forwardJumpInterval: NSNumber(value: playerOptions.forwardJumpInterval),
945
- backwardJumpInterval: NSNumber(value: playerOptions.backwardJumpInterval),
946
- playbackRates: playerOptions.playbackRates,
947
- )
948
-
949
- player.remoteCommands = remoteCommands
950
- }
951
-
952
- public func getOptions() throws -> Options {
953
- onMainActor { playerOptions.toOptions() }
954
- }
955
-
956
- /// Stored so a re-setup can re-apply it: unlike the other runtime options this
957
- /// one has no home in `playerOptions`, and the player it was pushed to does not
958
- /// survive `setupPlayer`.
959
- @MainActor private var playbackIntervalEnabled = false
960
-
961
- public func setPlaybackIntervalEnabled(enabled: Bool) {
962
- onMainActor {
963
- playbackIntervalEnabled = enabled
964
- player?.setPlaybackIntervalEnabled(enabled)
965
- }
966
- }
967
-
968
- // MARK: - Playback Control
969
-
970
- public func load(track: Track) throws {
971
- try onMainActor {
972
- guard let player else {
973
- throw NSError(domain: "AudioBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Player not initialized"])
974
- }
975
- player.load(track)
976
- }
977
- }
978
-
979
- public func reset() throws {
980
- onMainActor { player?.clear() }
981
- }
982
-
983
- public func play() throws {
984
- onMainActor { player?.play() }
985
- }
986
-
987
- public func pause() throws {
988
- onMainActor { player?.pause() }
989
- }
990
-
991
- public func togglePlayback() throws {
992
- onMainActor { player?.togglePlayback() }
993
- }
994
-
995
- public func stop() throws {
996
- onMainActor { player?.stop() }
997
- }
998
-
999
- public func setPlayWhenReady(playWhenReady: Bool) throws {
1000
- onMainActor {
1001
- // Pre-setup this stages the intent for the player to come up with — never a no-op.
1002
- if let player { player.playWhenReady = playWhenReady }
1003
- else { pendingPlayWhenReady = playWhenReady }
1004
- }
1005
- }
1006
-
1007
- public func getPlayWhenReady() throws -> Bool {
1008
- onMainActor { player?.playWhenReady ?? pendingPlayWhenReady ?? false }
1009
- }
1010
-
1011
- public func seekTo(position: Double) throws {
1012
- onMainActor { player?.seekTo(position) }
1013
- }
1014
-
1015
- public func seekBy(offset: Double) throws {
1016
- onMainActor { player?.seekBy(offset) }
1017
- }
1018
-
1019
- public func seekToLiveEdge() throws {
1020
- onMainActor { player?.seekToLiveEdge() }
1021
- }
1022
-
1023
- public func setVolume(level: Double) throws {
1024
- onMainActor { player?.volume = Float(level) }
1025
- }
1026
-
1027
- public func getVolume() throws -> Double {
1028
- onMainActor { Double(player?.volume ?? 1.0) }
1029
- }
1030
-
1031
- public func setRate(rate: Double) throws {
1032
- onMainActor { player?.rate = Float(rate) }
1033
- }
1034
-
1035
- public func getRate() throws -> Double {
1036
- onMainActor { Double(player?.rate ?? 1.0) }
1037
- }
1038
-
1039
- public func getProgress() throws -> Progress {
1040
- onMainActor {
1041
- Progress(
1042
- position: player?.currentTime ?? 0,
1043
- duration: player?.duration ?? 0,
1044
- buffered: player?.bufferedPosition ?? 0,
1045
- )
1046
- }
1047
- }
1048
-
1049
- public func getPlayback() throws -> Playback {
1050
- onMainActor { player?.getPlayback() ?? Playback(state: .none, error: nil) }
1051
- }
1052
-
1053
- public func getPlayingState() throws -> PlayingState {
1054
- onMainActor {
1055
- player?.playingStateManager.toPlayingState() ?? PlayingState(playing: false, buffering: false)
1056
- }
1057
- }
1058
-
1059
- public func getRepeatMode() throws -> RepeatMode {
1060
- onMainActor { player?.repeatMode ?? pendingRepeatMode ?? .off }
1061
- }
1062
-
1063
- public func setRepeatMode(mode: RepeatMode) throws {
1064
- onMainActor {
1065
- // Pre-setup this stages the mode for the player to come up with — never a no-op.
1066
- if let player { player.repeatMode = mode }
1067
- else { pendingRepeatMode = mode }
1068
- }
1069
- }
1070
-
1071
- public func getShuffleEnabled() throws -> Bool {
1072
- onMainActor { player?.shuffleEnabled ?? false }
1073
- }
1074
-
1075
- public func setShuffleEnabled(enabled: Bool) throws {
1076
- onMainActor { player?.shuffleEnabled = enabled }
1077
- }
1078
-
1079
- public func getPlaybackError() throws -> PlaybackError? {
1080
- onMainActor { player?.coordinator.nitroPlaybackError() }
1081
- }
1082
-
1083
- public func retry() throws {
1084
- // Re-resolve rather than replay the cached URL: short-lived URLs/tokens
1085
- // may have expired since the failure (matches the internal retry path).
1086
- onMainActor { player?.reloadResolving(startFromCurrentTime: true) }
1087
- }
1088
-
1089
- // MARK: - Sleep Timer
1090
-
1091
- public func getSleepTimer() throws -> SleepTimer {
1092
- onMainActor {
1093
- if let state = player?.sleepTimerManager.get() {
1094
- return state
1095
- }
1096
- return .first(NullType.null)
1097
- }
1098
- }
1099
-
1100
- public func setSleepTimer(seconds: Double, fadeDuration: Double?) throws {
1101
- onMainActor {
1102
- player?.sleepTimerManager.set(seconds: seconds, fadeDuration: fadeDuration)
1103
- }
1104
- }
1105
-
1106
- public func setSleepTimerToEndOfTrack() throws {
1107
- onMainActor {
1108
- player?.sleepTimerManager.setToEndOfTrack()
1109
- }
1110
- }
1111
-
1112
- public func clearSleepTimer() throws -> Bool {
1113
- onMainActor {
1114
- player?.sleepTimerManager.clear() ?? false
1115
- }
1116
- }
1117
-
1118
- // MARK: - Queue Management
1119
-
1120
- public func add(tracks: [Track], insertBeforeIndex: Double?) throws {
1121
- try onMainActor {
1122
- guard let player else { return }
1123
- if let index = insertBeforeIndex {
1124
- try player.add(tracks, at: Int(index))
1125
- } else {
1126
- player.add(tracks)
1127
- }
1128
- }
1129
- }
1130
-
1131
- public func move(fromIndex: Double, toIndex: Double) throws {
1132
- try onMainActor {
1133
- try player?.move(fromIndex: Int(fromIndex), toIndex: Int(toIndex))
1134
- }
1135
- }
1136
-
1137
- public func remove(indexes: [Double]) throws {
1138
- try onMainActor {
1139
- guard let player else { return }
1140
- // Remove in reverse order to maintain index validity
1141
- for index in indexes.sorted().reversed() {
1142
- try player.remove(Int(index))
1143
- }
1144
- }
1145
- }
1146
-
1147
- public func removeUpcomingTracks() throws {
1148
- onMainActor { player?.removeUpcomingTracks() }
1149
- }
1150
-
1151
- public func skip(index: Double, initialPosition: Double?) throws {
1152
- try onMainActor {
1153
- try player?.skipTo(Int(index))
1154
- if let position = initialPosition {
1155
- player?.seekTo(position)
1156
- }
1157
- }
1158
- }
1159
-
1160
- public func skipToNext(initialPosition: Double?) throws {
1161
- onMainActor {
1162
- player?.next()
1163
- if let position = initialPosition {
1164
- player?.seekTo(position)
1165
- }
1166
- }
1167
- }
1168
-
1169
- public func skipToPrevious(initialPosition: Double?) throws {
1170
- onMainActor {
1171
- player?.previous()
1172
- if let position = initialPosition {
1173
- player?.seekTo(position)
1174
- }
1175
- }
1176
- }
1177
-
1178
- public func setActiveTrackFavorited(favorited: Bool) throws {
1179
- try onMainActor {
1180
- guard let track = player?.currentTrack, let identity = track.identity else { return }
1181
- guard let index = player?.currentIndex, index >= 0 else { return }
1182
- // Optimistically reflect in the authoritative favorite set so the
1183
- // now-playing heart (hydrated from it in getActiveTrack) flips
1184
- // immediately; the consumer reconciles to its canonical identities on
1185
- // the next setFavorites.
1186
- browserManager.setFavorited(identity: identity, favorited: favorited)
1187
- let updatedTrack = track.copying(favorited: favorited)
1188
- // Hydration never overrides a caller-set `favorited`, so the queued
1189
- // track must itself carry the latest explicit value (as Android's tag
1190
- // swap and web's replaceTrack do).
1191
- try player?.replace(index, updatedTrack)
1192
- // The ONLY emit: a favorite toggle is an in-place mutation of the active
1193
- // track, not a transition — onPlaybackActiveTrackChanged stays
1194
- // transition-only, and the useActiveTrack / useQueue hooks subscribe to
1195
- // onFavoriteChanged themselves. Native surfaces (CarPlay heart) observe
1196
- // favoriteChangedEmitter / onFavoritesChanged directly.
1197
- favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updatedTrack, favorited: favorited))
1198
- }
1199
- }
1200
-
1201
- /// Whether the active (now-playing) track is favorited, per the authoritative
1202
- /// favorite set (via `getActiveTrack`'s hydration).
1203
- func isActiveTrackFavorited() -> Bool {
1204
- (try? getActiveTrack())?.favorited ?? false
1205
- }
1206
-
1207
- public func toggleActiveTrackFavorited() throws {
1208
- try? setActiveTrackFavorited(favorited: !isActiveTrackFavorited())
1209
- }
1210
-
1211
- public func setQueue(tracks: [Track], startIndex: Double?, startPosition: Double?) throws {
1212
- onMainActor {
1213
- guard let player else { return }
1214
- player.setQueue(
1215
- tracks,
1216
- initialIndex: startIndex.map { Int($0) } ?? 0,
1217
- startPositionMs: startPosition.map { $0 * 1000 },
1218
- )
1219
- }
1220
- }
1221
-
1222
- public func getQueue() throws -> [Track] {
1223
- onMainActor { player?.tracks ?? [] }
1224
- }
1225
-
1226
- public func getTrack(index: Double) throws -> Track? {
1227
- onMainActor {
1228
- guard let tracks = player?.tracks else { return nil }
1229
- let i = Int(index)
1230
- guard i >= 0, i < tracks.count else { return nil }
1231
- return tracks[i]
1232
- }
1233
- }
1234
-
1235
- public func getActiveTrackIndex() throws -> Double? {
1236
- onMainActor {
1237
- guard let index = player?.currentIndex, index >= 0 else { return nil }
1238
- return Double(index)
1239
- }
1240
- }
1241
-
1242
- public func getActiveTrack() throws -> Track? {
1243
- // Hydrate `favorited` from the authoritative favorite set — the now-playing
1244
- // track is loaded outside the browse cache, so its own flag isn't otherwise
1245
- // kept in sync as favorites change. This is the single source of truth for
1246
- // "is the active track favorited" (now-playing heart, toggle direction, JS).
1247
- onMainActor { player?.currentTrack.map(browserManager.hydrateFavorite) }
1248
- }
1249
-
1250
- // MARK: - Now Playing
1251
-
1252
- public func updateNowPlaying(update: NowPlayingUpdate?) throws {
1253
- onMainActor {
1254
- nowPlayingOverride = update
1255
- applyNowPlayingMetadata()
1256
- }
1257
- }
1258
-
1259
- /// Transient now-playing fields (e.g. feedback for a refused remote
1260
- /// command). Outranks the formatter and the override while active, so live
1261
- /// metadata can't stomp it mid-flash. Reverted by a NATIVE timer — JS
1262
- /// timers pause with a backgrounded host on Android, and the lock screen
1263
- /// is exactly the backgrounded case — and cleared early on track change.
1264
- @MainActor private var nowPlayingFlash: NowPlayingUpdate?
1265
- @MainActor private var nowPlayingFlashRevert: DispatchWorkItem?
1266
-
1267
- /// Pending debounced audio-session release (see `playerShouldReleaseSession`).
1268
- @MainActor private var sessionReleaseWork: DispatchWorkItem?
1269
-
1270
- public func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
1271
- onMainActor {
1272
- nowPlayingFlashRevert?.cancel()
1273
- nowPlayingFlash = update
1274
- let revert = DispatchWorkItem { [weak self] in
1275
- guard let self else { return }
1276
- self.nowPlayingFlash = nil
1277
- self.nowPlayingFlashRevert = nil
1278
- self.applyNowPlayingMetadata()
1279
- }
1280
- nowPlayingFlashRevert = revert
1281
- DispatchQueue.main.asyncAfter(deadline: .now() + durationMs / 1000, execute: revert)
1282
- applyNowPlayingMetadata()
1283
- }
1284
- }
1285
-
1286
- public func clearNowPlayingFlash() throws {
1287
- onMainActor {
1288
- guard nowPlayingFlash != nil else { return }
1289
- cancelNowPlayingFlash()
1290
- applyNowPlayingMetadata()
1291
- }
1292
- }
1293
-
1294
- @MainActor
1295
- private func cancelNowPlayingFlash() {
1296
- nowPlayingFlashRevert?.cancel()
1297
- nowPlayingFlashRevert = nil
1298
- nowPlayingFlash = nil
1299
- }
1300
-
1301
- public func getNowPlaying() throws -> NowPlayingMetadata? {
1302
- onMainActor {
1303
- guard let track = player?.currentTrack else { return nil }
1304
- let flash = nowPlayingFlash
1305
- let override = nowPlayingOverride
1306
- return makeNowPlayingMetadata(
1307
- track: track,
1308
- title: flash?.title ?? override?.title ?? track.title,
1309
- artist: flash?.artist ?? override?.artist ?? track.artist,
1310
- album: flash?.album ?? override?.album ?? track.album,
1311
- )
1312
- }
1313
- }
1314
-
1315
- /// Returns the artwork URI suitable for JS consumption.
1316
- /// Prefers the resolved artworkSource URI (e.g. file:// for rendered SF Symbols).
1317
- /// Falls back to the raw artwork string only if it's not an SF Symbol (which JS can't load).
1318
- private func nowPlayingArtwork(for track: Track) -> String? {
1319
- if let uri = track.artworkSource?.uri { return uri }
1320
- guard let artwork = track.artwork?.url else { return nil }
1321
- return SFSymbolRenderer.isSFSymbol(artwork) ? nil : artwork
1322
- }
1323
-
1324
- /// Builds the JS-facing now-playing metadata for the given rendered text lines.
1325
- @MainActor
1326
- private func makeNowPlayingMetadata(
1327
- track: Track, title: String, artist: String?, album: String?,
1328
- ) -> NowPlayingMetadata {
1329
- NowPlayingMetadata(
1330
- elapsedTime: player?.currentTime,
1331
- title: title,
1332
- album: album,
1333
- artist: artist,
1334
- duration: track.duration,
1335
- artwork: nowPlayingArtwork(for: track),
1336
- description: track.description,
1337
- mediaId: track.src ?? track.path,
1338
- genre: track.genre,
1339
- )
1340
- }
1341
-
1342
- /// Re-renders the now-playing surface via `NowPlayingUpdater`, handing it the current playback
1343
- /// signals, override, and formatter. Called on every track / metadata / playback-state change.
1344
- @MainActor
1345
- private func applyNowPlayingMetadata() {
1346
- guard let player, let track = player.currentTrack else { return }
1347
- // Classify a stall by connectivity so the formatter can show "No internet connection" vs
1348
- // "Reconnecting…" without a separate read. nil while not stalled.
1349
- let stalled: StallReason? =
1350
- player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
1351
- player.nowPlayingUpdater.render(
1352
- track: track,
1353
- timedMetadata: latestTimedMetadata,
1354
- playWhenReady: player.playWhenReady,
1355
- stalled: stalled,
1356
- error: player.coordinator.nitroPlaybackError(),
1357
- flash: nowPlayingFlash,
1358
- override: nowPlayingMetadataEnabled ? nowPlayingOverride : nil,
1359
- formatter: nowPlayingMetadataEnabled ? nowPlayingMetadataFormatter : nil,
1360
- )
1361
- }
1362
-
1363
- // MARK: - Network
1364
-
1365
- public func getOnline() throws -> Bool {
1366
- // No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
1367
- // cheap read on the JS thread instead of blocking on the main queue.
1368
- networkMonitor.isOnline
1369
- }
1370
-
1371
- // MARK: - System Volume
1372
-
1373
- /// Sets up KVO observer for system volume changes
1374
- private func setupVolumeObserver() {
1375
- // Remove existing observation if any
1376
- volumeObservation?.invalidate()
1377
-
1378
- let audioSession = AVAudioSession.sharedInstance()
1379
- volumeObservation = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] _, change in
1380
- guard let newVolume = change.newValue else { return }
1381
- self?.onSystemVolumeChanged(Double(newVolume))
1382
- }
1383
- }
1384
-
1385
- public func getSystemVolume() throws -> Double {
1386
- Double(AVAudioSession.sharedInstance().outputVolume)
1387
- }
1388
-
1389
- public func setSystemVolume(volume _: Double) throws {
1390
- // iOS doesn't provide a public API to set system volume programmatically.
1391
- // Users must adjust volume via hardware buttons or Control Center.
1392
- logger.debug("setSystemVolume is not supported on iOS - volume must be adjusted via hardware buttons or Control Center")
1393
- }
1394
-
1395
- // MARK: - External Audio Output
1396
-
1397
- /// Observes audio-session interruptions (phone calls, or another app such as
1398
- /// Music/Spotify starting playback). iOS pauses our AVPlayer underneath us but
1399
- /// doesn't update our playback state, so without this the UI stays stuck on
1400
- /// "playing". On `.began` we reflect a real pause; on `.ended` we resume if we
1401
- /// were playing and the system says we should.
1402
- private func setupInterruptionObserver() {
1403
- if let observer = interruptionObserver {
1404
- NotificationCenter.default.removeObserver(observer)
1405
- }
1406
-
1407
- interruptionObserver = NotificationCenter.default.addObserver(
1408
- forName: AVAudioSession.interruptionNotification,
1409
- object: nil,
1410
- queue: .main,
1411
- ) { [weak self] notification in
1412
- self?.handleAudioSessionInterruption(notification)
1413
- }
1414
- }
1415
-
1416
- private func handleAudioSessionInterruption(_ notification: Notification) {
1417
- guard
1418
- let info = notification.userInfo,
1419
- let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
1420
- let type = AVAudioSession.InterruptionType(rawValue: typeValue)
1421
- else { return }
1422
-
1423
- switch type {
1424
- case .began:
1425
- // iOS 17+ fires a `.began` (with no matching `.ended`) when an output
1426
- // route such as headphones disappears. Reflecting a pause here would
1427
- // strand resume-intent state with nothing to clear it, and the
1428
- // route-change observer already handles the output change — so ignore it.
1429
- if #available(iOS 17.0, *),
1430
- let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
1431
- AVAudioSession.InterruptionReason(rawValue: reasonValue) == .routeDisconnected
1432
- {
1433
- return
1434
- }
1435
- onMainActor { player?.handleInterruptionBegan() }
1436
- case .ended:
1437
- let shouldResume = (info[AVAudioSessionInterruptionOptionKey] as? UInt)
1438
- .map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) }
1439
- ?? false
1440
- onMainActor {
1441
- // Reactivate the possibly-deactivated session before resuming — but
1442
- // only when a resume will happen: activating while staying paused
1443
- // grabs a non-mixable session with nothing to release it.
1444
- if shouldResume, player?.willResumeAfterInterruption == true {
1445
- try? AVAudioSession.sharedInstance().setActive(true)
1446
- }
1447
- player?.handleInterruptionEnded(shouldResume: shouldResume)
1448
- }
1449
- @unknown default:
1450
- break
1451
- }
1452
- }
1453
-
1454
- /// Sets up observer for audio route changes
1455
- private func setupRouteChangeObserver() {
1456
- if let observer = routeChangeObserver {
1457
- NotificationCenter.default.removeObserver(observer)
1458
- }
1459
-
1460
- routeChangeObserver = NotificationCenter.default.addObserver(
1461
- forName: AVAudioSession.routeChangeNotification,
1462
- object: nil,
1463
- queue: .main,
1464
- ) { [weak self] notification in
1465
- guard let self else { return }
1466
- // The previous output disappeared (headphones unplugged, a Bluetooth
1467
- // speaker powered off). Pause rather than abruptly resume out of the
1468
- // built-in speaker — the platform convention every media app follows.
1469
- // A deliberate pause (clears play intent), not an interruption: the user
1470
- // restarts playback themselves once they want it.
1471
- if let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
1472
- AVAudioSession.RouteChangeReason(rawValue: reasonValue) == .oldDeviceUnavailable
1473
- {
1474
- // Also cancels a pending interruption auto-resume: unplugging during
1475
- // a call must not blast the built-in speaker when the call ends.
1476
- self.onMainActor { self.player?.handleRouteDisconnected() }
1477
- }
1478
- if let output = self.getCurrentOutput() {
1479
- self.onOutputChanged(output)
1480
- }
1481
- }
1482
- }
1483
-
1484
- /// Observes audio-server (`mediaserverd`) resets. When it fires, the session
1485
- /// category is cleared and every AVPlayer/session handle is invalid — a
1486
- /// long-running stream would otherwise go permanently silent until the app
1487
- /// restarts.
1488
- private func setupMediaServicesResetObserver() {
1489
- if let observer = mediaServicesResetObserver {
1490
- NotificationCenter.default.removeObserver(observer)
1491
- }
1492
- mediaServicesResetObserver = NotificationCenter.default.addObserver(
1493
- forName: AVAudioSession.mediaServicesWereResetNotification,
1494
- object: nil,
1495
- queue: .main,
1496
- ) { [weak self] _ in
1497
- guard let self else { return }
1498
- // Re-apply our category (the reset cleared it), then have the player
1499
- // recreate itself and reconnect the current stream.
1500
- self.onMainActor {
1501
- self.applyAudioSessionCategory()
1502
- self.player?.handleMediaServicesReset()
1503
- }
1504
- }
1505
- }
1506
-
1507
- /// Gets the current audio output info
1508
- private func getCurrentOutput() -> Output? {
1509
- let session = AVAudioSession.sharedInstance()
1510
- guard let output = session.currentRoute.outputs.first else { return nil }
1511
-
1512
- let (outputType, external): (OutputType, Bool) = switch output.portType {
1513
- case .builtInSpeaker:
1514
- (.speaker, false)
1515
- case .builtInReceiver:
1516
- (.receiver, false)
1517
- case .airPlay:
1518
- (.airplay, true)
1519
- case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE:
1520
- (.bluetooth, true)
1521
- case .headphones:
1522
- (.headphones, true)
1523
- case .carAudio:
1524
- (.car, true)
1525
- case .HDMI:
1526
- (.hdmi, true)
1527
- case .usbAudio:
1528
- (.usb, true)
1529
- default:
1530
- (.other, true)
1531
- }
1532
-
1533
- return Output(type: outputType, name: output.portName, external: external)
1534
- }
1535
-
1536
- public func getOutput() throws -> Output? {
1537
- getCurrentOutput()
1538
- }
1539
-
1540
- public func supportsOutputSwitcher() throws -> Bool {
1541
- // iOS can always present the system route picker (AVRoutePickerView).
1542
- true
1543
- }
1544
-
1545
- public func openOutputPicker() throws {
1546
- DispatchQueue.main.async { [weak self] in
1547
- guard let self else { return }
1548
- guard let windowScene = UIApplication.shared.activeWindowScene,
1549
- let window = windowScene.windows.first(where: { $0.isKeyWindow }),
1550
- var topController = window.rootViewController
1551
- else {
1552
- self.logger.error("openOutputPicker: no active window scene / root view controller")
1553
- return
1554
- }
1555
-
1556
- // Walk up to the topmost presented view controller
1557
- while let presented = topController.presentedViewController {
1558
- topController = presented
1559
- }
1560
-
1561
- // AVRoutePickerView has no public API to present its picker
1562
- // programmatically, so we add it to the hierarchy and tap its internal
1563
- // button. Two requirements that are easy to get wrong:
1564
- // 1. The view must be *visible* to the system (a `isHidden` source view
1565
- // suppresses the presentation), so we keep it transparent instead.
1566
- // 2. The internal button can be nested below the top-level subviews on
1567
- // modern iOS, so we search recursively rather than only direct children.
1568
- let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
1569
- routePicker.alpha = 0.01
1570
- topController.view.addSubview(routePicker)
1571
-
1572
- let button = Self.firstButton(in: routePicker)
1573
- self.logger.notice(
1574
- "openOutputPicker: hierarchy=\(Self.describeHierarchy(routePicker), privacy: .public), buttonFound=\(button != nil, privacy: .public)",
1575
- )
1576
-
1577
- if let button {
1578
- button.sendActions(for: .touchUpInside)
1579
- } else {
1580
- self.logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
1581
- }
1582
-
1583
- // Keep the source view in the hierarchy long enough for the picker to
1584
- // present (on iPad it anchors a popover to this view).
1585
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
1586
- routePicker.removeFromSuperview()
1587
- }
1588
- }
1589
- }
1590
-
1591
- /// Depth-first search for the first `UIButton` inside a view tree.
1592
- private static func firstButton(in view: UIView) -> UIButton? {
1593
- if let button = view as? UIButton { return button }
1594
- for subview in view.subviews {
1595
- if let button = firstButton(in: subview) { return button }
1596
- }
1597
- return nil
1598
- }
1599
-
1600
- /// Compact description of a view tree, e.g. `AVRoutePickerView[UIButton[UIImageView]]`.
1601
- private static func describeHierarchy(_ view: UIView) -> String {
1602
- let name = String(describing: type(of: view))
1603
- let children = view.subviews.map(describeHierarchy).joined(separator: ", ")
1604
- return children.isEmpty ? name : "\(name)[\(children)]"
1605
- }
1606
-
1607
- // MARK: - Equalizer (unsupported on iOS)
1608
-
1609
- public func getEqualizerSettings() throws -> EqualizerSettings? {
1610
- // No-op: equalizer unsupported on iOS
1611
- nil
1612
- }
1613
-
1614
- public func setEqualizerEnabled(enabled _: Bool) throws {
1615
- // No-op: equalizer unsupported on iOS
1616
- }
1617
-
1618
- public func setEqualizerPreset(preset _: String) throws {
1619
- // No-op: equalizer unsupported on iOS
1620
- }
1621
-
1622
- public func setEqualizerLevels(levels _: [Double]) throws {
1623
- // No-op: equalizer unsupported on iOS
1624
- }
1625
-
1626
- // MARK: - Battery (Android-only, stub implementations)
1627
-
1628
- public func getBatteryWarningPending() throws -> Bool {
1629
- false
1630
- }
1631
-
1632
- public func getBatteryOptimizationStatus() throws -> BatteryOptimizationStatus {
1633
- // iOS doesn't have battery optimization restrictions like Android
1634
- .unrestricted
1635
- }
1636
-
1637
- public func dismissBatteryWarning() throws {
1638
- // No-op on iOS
1639
- }
1640
-
1641
- public func openBatterySettings() throws {
1642
- // No-op on iOS
1643
- }
1644
- }
1645
-
1646
- // MARK: - TrackPlayerCallbacks
1647
-
1648
- extension HybridAudioBrowser: TrackPlayerCallbacks {
1649
- public func playerDidChangePlayback(_ playback: Playback) {
1650
- onPlaybackChanged(playback)
1651
- // Re-render so the formatter reflects the new state — notably the error line on a failure (the
1652
- // coordinator sets playbackError before this fires). Redundant updates are deduped on publish.
1653
- applyNowPlayingMetadata()
1654
- }
1655
-
1656
- public func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
1657
- // Clear now playing override + flash + live metadata when track changes
1658
- // (matches Kotlin behavior; a flash must not carry over to a new track)
1659
- nowPlayingOverride = nil
1660
- cancelNowPlayingFlash()
1661
- latestTimedMetadata = nil
1662
- // Hydrate the active track's `favorited` from the authoritative set so JS
1663
- // consumers (useActiveTrack) match getActiveTrack()'s hydrated value — the
1664
- // coordinator emits the raw queue track, whose flag isn't kept in sync.
1665
- let hydrated = PlaybackActiveTrackChangedEvent(
1666
- lastIndex: event.lastIndex,
1667
- lastTrack: event.lastTrack,
1668
- lastPosition: event.lastPosition,
1669
- index: event.index,
1670
- track: event.track.map(browserManager.hydrateFavorite),
1671
- )
1672
- activeTrackChangedEmitter.emit(hydrated)
1673
- // Also notify now playing changed when track changes
1674
- applyNowPlayingMetadata()
1675
- }
1676
-
1677
- public func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
1678
- onPlaybackProgressUpdated(event)
1679
- }
1680
-
1681
- public func playerDidFirePlaybackInterval() {
1682
- onPlaybackInterval()
1683
- }
1684
-
1685
- public func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
1686
- // Intent to play again is the earliest cancel signal for a pending session release.
1687
- if playWhenReady { cancelSessionRelease() }
1688
- onPlaybackPlayWhenReadyChanged(PlaybackPlayWhenReadyChangedEvent(playWhenReady: playWhenReady))
1689
- }
1690
-
1691
- public func playerDidChangePlayingState(_ state: PlayingState) {
1692
- onPlaybackPlayingState(state)
1693
- // Lazily activate the audio session the moment playback actually produces — or is buffering to
1694
- // produce — output. This (not setup) is where we grab the session, so launching the app never
1695
- // interrupts other apps' audio; the interruption only happens when the user truly starts
1696
- // playback, which is correct. Keyed off play *state*, not `playWhenReady`, because the intent
1697
- // can be set at setup (before any track) while output only begins here. Idempotent:
1698
- // `setActive(true)` is a no-op when the session is already active.
1699
- if state.playing || state.buffering {
1700
- try? AVAudioSession.sharedInstance().setActive(true)
1701
- // The now-playing session's activation request at link time predates this
1702
- // (the audio session was still inactive, and the system may have declined);
1703
- // re-request now so the surface can appear even if playback never produces
1704
- // audio (first-ever load failing on a dead stream).
1705
- player?.nowPlayingInfoController.reactivateSessionIfNeeded()
1706
- // Output resumed — cancel any pending release armed by an earlier stop.
1707
- cancelSessionRelease()
1708
- }
1709
- // Re-render so the formatter reacts to a stall starting or recovering (`stalled`) — a buffering
1710
- // flag change that may not transition the coordinator state.
1711
- applyNowPlayingMetadata()
1712
- }
1713
-
1714
- public func playerShouldReleaseSession() {
1715
- // Release the audio session so other apps' audio (Spotify, a podcast) can resume — debounced,
1716
- // because iOS dislikes deactivating while our audio is still trailing off, and a quick
1717
- // pause→resume shouldn't thrash the session. The coordinator only requests this on a genuine
1718
- // stop (not an interruption, not mid-retry); a re-activation cancels it (see
1719
- // `playerDidChangePlayWhenReady` and the activation above). Mirrors the now-playing flash timer.
1720
- sessionReleaseWork?.cancel()
1721
- let release = DispatchWorkItem { [weak self] in
1722
- guard let self, self.player?.playWhenReady == false else { return }
1723
- self.sessionReleaseWork = nil
1724
- // `.notifyOthersOnDeactivation` signals other apps they may resume; without it the session
1725
- // is released but nobody is told, so the user's audio would not come back.
1726
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
1727
- }
1728
- sessionReleaseWork = release
1729
- DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
1730
- }
1731
-
1732
- @MainActor
1733
- private func cancelSessionRelease() {
1734
- sessionReleaseWork?.cancel()
1735
- sessionReleaseWork = nil
1736
- }
1737
-
1738
- public func playerDidEndQueue(_ event: PlaybackQueueEndedEvent) {
1739
- onPlaybackQueueEnded(event)
1740
- }
1741
-
1742
- public func playerDidChangeQueue(_ tracks: [Track]) {
1743
- queueChangedEmitter.emit(tracks)
1744
- }
1745
-
1746
- public func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
1747
- repeatModeChangedEmitter.emit(event)
1748
- }
1749
-
1750
- public func playerDidChangeShuffleEnabled(_ enabled: Bool) {
1751
- shuffleChangedEmitter.emit(enabled)
1752
- onPlaybackShuffleModeChanged(enabled)
1753
- }
1754
-
1755
- public func playerDidError(_ event: PlaybackErrorEvent) {
1756
- onPlaybackError(event)
1757
- }
1758
-
1759
- public func playerDidReceiveCommonMetadata(_ items: [AVMetadataItem]) {
1760
- let metadata = TrackMetadata.from(items: items)
1761
- onTrackMetadata(metadata)
1762
- }
1763
-
1764
- public func playerDidReceiveTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
1765
- for group in groups {
1766
- if let metadata = TimedMetadata.from(items: group.items, icyCharset: player?.currentTrack?.icyCharset) {
1767
- latestTimedMetadata = metadata
1768
- onTimedMetadata(metadata)
1769
- // Re-render the now-playing so the formatter can reflect the live song.
1770
- applyNowPlayingMetadata()
1771
- }
1772
- }
1773
- }
1774
-
1775
- public func playerDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
1776
- let chapters = ChapterMetadata.from(groups: groups)
1777
- onChapterMetadata(chapters)
1778
- }
1779
-
1780
- public func playerDidCompleteSeek(position _: Double, didFinish _: Bool) {
1781
- // Not exposed to JS currently
1782
- }
1783
-
1784
- public func playerDidUpdateDuration(_: Double) {
1785
- // Not exposed to JS currently
1786
- }
1787
-
1788
- public func remotePlay() {
1789
- if let handler = handleRemotePlay {
1790
- handler()
1791
- } else {
1792
- // Default behavior: play the player
1793
- onMainActor { player?.play() }
1794
- }
1795
- onRemotePlay()
1796
- }
1797
-
1798
- public func remotePause() {
1799
- if let handler = handleRemotePause {
1800
- handler()
1801
- } else {
1802
- // Default behavior: pause the player
1803
- onMainActor { player?.pause() }
1804
- }
1805
- onRemotePause()
1806
- }
1807
-
1808
- public func remoteStop() {
1809
- if let handler = handleRemoteStop {
1810
- handler()
1811
- } else {
1812
- // Default behavior: stop the player
1813
- onMainActor { player?.stop() }
1814
- }
1815
- onRemoteStop()
1816
- }
1817
-
1818
- public func remotePlayPause() {
1819
- // Toggle based on current state
1820
- let isPlaying = onMainActor { player?.playWhenReady == true }
1821
- if isPlaying {
1822
- remotePause()
1823
- } else {
1824
- remotePlay()
1825
- }
1826
- }
1827
-
1828
- public func remoteNext() {
1829
- if let handler = handleRemoteNext {
1830
- handler()
1831
- } else {
1832
- // Default behavior: skip to next track
1833
- onMainActor { player?.next() }
1834
- }
1835
- onRemoteNext()
1836
- }
1837
-
1838
- public func remotePrevious() {
1839
- if let handler = handleRemotePrevious {
1840
- handler()
1841
- } else {
1842
- // Default behavior: skip to previous track
1843
- onMainActor { player?.previous() }
1844
- }
1845
- onRemotePrevious()
1846
- }
1847
-
1848
- public func remoteJumpForward(interval: Double) {
1849
- let event = RemoteJumpForwardEvent(interval: interval)
1850
- if let handler = handleRemoteJumpForward {
1851
- handler(event)
1852
- } else {
1853
- // Default behavior: seek forward by interval
1854
- onMainActor { player?.seekBy(interval) }
1855
- }
1856
- onRemoteJumpForward(event)
1857
- }
1858
-
1859
- public func remoteJumpBackward(interval: Double) {
1860
- let event = RemoteJumpBackwardEvent(interval: interval)
1861
- if let handler = handleRemoteJumpBackward {
1862
- handler(event)
1863
- } else {
1864
- // Default behavior: seek backward by interval
1865
- onMainActor { player?.seekBy(-interval) }
1866
- }
1867
- onRemoteJumpBackward(event)
1868
- }
1869
-
1870
- public func remoteSeek(position: Double) {
1871
- logger.info("remoteSeek called with position: \(position)")
1872
- let event = RemoteSeekEvent(position: position)
1873
- if let handler = handleRemoteSeek {
1874
- handler(event)
1875
- } else {
1876
- // Default behavior: seek to position
1877
- logger.info("remoteSeek: calling player.seekTo(\(position))")
1878
- onMainActor { player?.seekTo(position) }
1879
- }
1880
- onRemoteSeek(event)
1881
- }
1882
-
1883
- public func remoteChangePlaybackPosition(position: Double) {
1884
- remoteSeek(position: position)
1885
- }
1886
-
1887
- public func remotePlayId(id: String, index: Int?) {
1888
- let event = RemotePlayIdEvent(id: id, index: index.map { Double($0) })
1889
- if let handler = handleRemotePlayId {
1890
- handler(event)
1891
- } else {
1892
- onRemotePlayId(event)
1893
- }
1894
- }
1895
-
1896
- public func remotePlaySearch(query: String) {
1897
- let event = RemotePlaySearchEvent(query: query)
1898
- if let handler = handleRemotePlaySearch {
1899
- handler(event)
1900
- } else {
1901
- onRemotePlaySearch(event)
1902
- }
1903
- }
1904
-
1905
- /// Static so the Siri intent handler can call it before the shared instance
1906
- /// exists: it waits on the `playerAndConfiguredBrowser` gate, which resolves
1907
- /// once RN boots. Uses the resolved `browser`, never `self`.
1908
- static func handlePlayMediaIntent(criteria: MediaIntentCriteria, completion: @escaping @Sendable (Bool) -> Void) {
1909
- Task { @MainActor in
1910
- guard let (browser, player) = await playerAndConfiguredBrowser.wait(timeout: .seconds(8)) else {
1911
- Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
1912
- .error("handlePlayMediaIntent: browser/player not ready within budget")
1913
- completion(false)
1914
- return
1915
- }
1916
- // No-criteria intent ("play «app»") → resume. The gate must NOT block
1917
- // this: the gate blocks *finding* content, never *hearing* the active or
1918
- // persisted track. The gate is checked below, for the search branch only.
1919
- if criteria.isResume {
1920
- if player.currentTrack != nil {
1921
- browser.logger.info("resume: warm — playing current track")
1922
- player.play()
1923
- browser.showNowPlayingRequestedEmitter.emit(())
1924
- completion(true)
1925
- } else if let state = browser.playbackStateStore.load() {
1926
- let track = state.track.toNitro()
1927
- // The persisted track can carry `disabled` — it became unavailable
1928
- // after it was saved. A disabled track never plays, resumption
1929
- // included (Track.disabled).
1930
- if track.disabled == true {
1931
- browser.logger.info("resume: persisted track is disabled — refusing")
1932
- completion(false)
1933
- return
1934
- }
1935
- browser.logger.info("resume: cold — restoring persisted track")
1936
- // Repeat / shuffle / speed come back before the queue is built — see
1937
- // `restorePlaybackSettings(from:)` for why that ordering matters.
1938
- player.restorePlaybackSettings(from: state)
1939
- let startMs = (track.live == true) ? nil : state.positionMs
1940
- // Match Android resume: re-expand the full queue from the track's contextual
1941
- // path (parent container → siblings + selected index). Fall back to the single
1942
- // track when the path isn't contextual or expansion fails.
1943
- if let path = state.track.path,
1944
- let expanded = try? await browser.browserManager.expandQueueFromContextualPath(path)
1945
- {
1946
- player.setQueue(expanded.tracks, initialIndex: expanded.selectedIndex, startPositionMs: startMs, playWhenReady: true)
1947
- } else {
1948
- player.setQueue([track], initialIndex: 0, startPositionMs: startMs, playWhenReady: true)
1949
- }
1950
- browser.showNowPlayingRequestedEmitter.emit(())
1951
- completion(true)
1952
- } else {
1953
- browser.logger.error("resume: nothing playing and nothing persisted → no-op")
1954
- completion(false) // nothing playing and nothing persisted
1955
- }
1956
- return
1957
- }
1958
-
1959
- // Assemble the Nitro SearchParams here (MainActor) from the criteria's
1960
- // Sendable fields, so the structured mode/genre/… reach the request like
1961
- // Android. Today the API still text-searches `q`; the rest is forward-compat.
1962
- let params = SearchParams(
1963
- mode: criteria.searchMode.flatMap { SearchMode(fromString: $0) },
1964
- query: criteria.query,
1965
- genre: criteria.genre,
1966
- artist: criteria.artist,
1967
- album: criteria.album,
1968
- title: criteria.title,
1969
- playlist: criteria.playlist,
1970
- // .currentlyPlaying can't reach here — isResume routed it to resume.
1971
- reference: criteria.reference == .my ? .my : .unknown,
1972
- )
1973
-
1974
- // Search is *finding* new content — refused when the gate decides this
1975
- // request is gated (resume above is unaffected: the gate never blocks
1976
- // hearing, and the gate check stays after the resume check).
1977
- let outcome = await browser.gateDecision(
1978
- for: NativeGateRequest(reason: .search, path: nil, search: params),
1979
- )
1980
- guard !outcome.gated else {
1981
- browser.logger.info("handlePlayMediaIntent: search refused — gated")
1982
- browser.onGate(GateEvent(reason: .search))
1983
- completion(false)
1984
- return
1985
- }
1986
-
1987
- do {
1988
- guard let tracks = try await browser.browserManager.searchPlayable(params) else {
1989
- completion(false)
1990
- return
1991
- }
1992
- player.setQueue(tracks, initialIndex: 0, playWhenReady: true)
1993
- browser.showNowPlayingRequestedEmitter.emit(())
1994
- completion(true)
1995
- } catch {
1996
- browser.logger.error("handlePlayMediaIntent failed: \(error.localizedDescription)")
1997
- completion(false)
1998
- }
1999
- }
2000
- }
2001
-
2002
- public func remoteChangeRepeatMode(mode: RepeatMode) {
2003
- // Apply the repeat mode change from CarPlay/lock screen
2004
- try? setRepeatMode(mode: mode)
2005
- }
2006
-
2007
- public func remoteChangeShuffleMode(enabled: Bool) {
2008
- // Apply the shuffle mode change from CarPlay/lock screen
2009
- try? setShuffleEnabled(enabled: enabled)
2010
- }
2011
-
2012
- public func remoteChangePlaybackRate(rate: Float) {
2013
- // Apply the playback rate change from CarPlay/lock screen
2014
- onMainActor { player?.rate = rate }
2015
- }
2016
-
2017
- func playerDidChangeOptions(_: PlayerUpdateOptions) {
2018
- // TODO: Convert to Options type
2019
- }
2020
407
  }
2021
408
 
2022
409
  // MARK: - Autolinking Alias