react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34

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 (56) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/Browser/BrowserConfig.swift +0 -5
  4. package/ios/Browser/BrowserError.swift +16 -0
  5. package/ios/Browser/BrowserManager.swift +36 -141
  6. package/ios/Browser/BrowserPathHelper.swift +25 -12
  7. package/ios/Browser/FavoriteSet.swift +46 -0
  8. package/ios/Browser/JsonModels.swift +79 -199
  9. package/ios/Browser/PageResolver.swift +16 -96
  10. package/ios/Browser/RequestLayer.swift +97 -2
  11. package/ios/Browser/RequestPipeline.swift +78 -227
  12. package/ios/Browser/SectionScope.swift +25 -0
  13. package/ios/Browser/SimpleRouter.swift +73 -144
  14. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  15. package/ios/CarPlay/CarPlaySession.swift +3 -3
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  18. package/ios/Extension/SearchParams+Query.swift +28 -0
  19. package/ios/Extension/Track+AVPlayer.swift +0 -16
  20. package/ios/Extension/Track+Copying.swift +3 -1
  21. package/ios/Extension/Track+Identity.swift +7 -0
  22. package/ios/Http/HttpClient.swift +12 -30
  23. package/ios/HybridAudioBrowser+Browser.swift +21 -38
  24. package/ios/HybridAudioBrowser+Options.swift +27 -66
  25. package/ios/HybridAudioBrowser+Player.swift +4 -60
  26. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  27. package/ios/HybridAudioBrowser.swift +36 -99
  28. package/ios/Model/NitroTypeStubs.swift +80 -0
  29. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  30. package/ios/Model/RemoteCommand.swift +6 -78
  31. package/ios/Player/NowPlayingUpdater.swift +13 -7
  32. package/ios/Player/Player.swift +26 -10
  33. package/ios/Player/PlayerCallbacks.swift +1 -4
  34. package/ios/Player/PlayerEngine.swift +17 -0
  35. package/ios/Player/RetryLoop.swift +5 -1
  36. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  37. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  38. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  39. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  40. package/ios/Tests/EmitterTests.swift +3 -48
  41. package/ios/Tests/FavoriteSetTests.swift +60 -0
  42. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  43. package/ios/Tests/PlayerTests.swift +95 -17
  44. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  45. package/ios/Tests/RequestLayerTests.swift +116 -0
  46. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  47. package/ios/Tests/SectionScopeTests.swift +53 -0
  48. package/ios/Tests/SimpleRouterTests.swift +38 -3
  49. package/ios/Tests/Support/TestTypes.swift +44 -3
  50. package/ios/TrackSelector.swift +34 -52
  51. package/ios/Util/Emitter.swift +0 -14
  52. package/ios/Util/NetworkMonitor.swift +8 -25
  53. package/ios/Util/OutputPicker.swift +48 -0
  54. package/package.json +1 -1
  55. package/ios/Model/SourceType.swift +0 -6
  56. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -10,12 +10,16 @@ public extension HybridAudioBrowser {
10
10
  /// synchronous: JS hears it before the navigation that may set the error again.
11
11
  internal func navigate(_ path: String, clearError: Bool) {
12
12
  if clearError { onMainActor { navigationError.clear() } }
13
- Task { @MainActor in
14
- do {
15
- try await browserManager.load(path)
16
- } catch {
17
- await reportNavigationError(error, path: path)
18
- }
13
+ Task { @MainActor in await loadPath(path) }
14
+ }
15
+
16
+ /// Loads `path` into the browser, reporting a failure as the navigation error.
17
+ @MainActor
18
+ private func loadPath(_ path: String) async {
19
+ do {
20
+ try await browserManager.load(path)
21
+ } catch {
22
+ await reportNavigationError(error, path: path)
19
23
  }
20
24
  }
21
25
 
@@ -33,9 +37,7 @@ public extension HybridAudioBrowser {
33
37
  }
34
38
 
35
39
  func navigateTrack(track: Track) throws {
36
- // @MainActor: `player` is main-confined, and `trackSelector.select` is
37
- // MainActor anyway — a bare Task read the reference off the cooperative
38
- // pool, racing the assignment in `setupPlayer`.
40
+ // @MainActor: `player` is main-confined.
39
41
  Task { @MainActor in
40
42
  guard let player else { return }
41
43
  let result = await trackSelector.select(track: track, player: player)
@@ -81,41 +83,22 @@ public extension HybridAudioBrowser {
81
83
  func notifyContentChanged(path: String) throws {
82
84
  // Throws on a contextual path (matching Android's `require`) — the caller
83
85
  // must pass the container path, or the invalidation silently hits nothing.
84
- try onMainActor { try browserManager.invalidateContentCache(path) }
85
-
86
- // Notify external controllers (CarPlay) that content changed
87
- externalContentChangedEmitter.emit(path)
88
-
89
- // Re-resolve the path if it's the current browser path
90
- if onMainActor({ browserManager.path }) == path {
91
- navigate(path, clearError: false)
86
+ let isCurrentPath = try onMainActor {
87
+ try browserManager.invalidateContentCache(path)
88
+ return browserManager.path == path
92
89
  }
90
+ externalContentChangedEmitter.emit(path)
91
+ if isCurrentPath { navigate(path, clearError: false) }
93
92
  }
94
93
 
95
94
  func invalidateAllContent() throws {
96
- onMainActor { browserManager.clearContentCache() }
97
-
98
- // Tell external controllers (CarPlay) to refresh every displayed template.
99
- externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
100
-
101
- // Re-query the tabs source too: a callback source can resolve to new
102
- // titles after an invalidation (e.g. a locale switch). BrowserManager
103
- // emits tabsChanged only when the result actually differs, and a failed
104
- // re-query keeps the current tabs. (Android needs no equivalent — its
105
- // root re-query already runs queryTabs(); the web stub re-queries tabs on
106
- // the re-navigation.)
107
- Task {
108
- _ = try? await browserManager.queryTabs()
109
- }
110
-
111
- // Re-resolve the JS-facing current path with the cache cleared.
112
- Task { @MainActor in
113
- do {
114
- try await browserManager.load(browserManager.path, bypassCache: true)
115
- } catch {
116
- await reportNavigationError(error, path: browserManager.path)
95
+ onMainActor {
96
+ browserManager.invalidateAll { error, path in
97
+ await self.reportNavigationError(error, path: path)
117
98
  }
118
99
  }
100
+ // Tell external controllers (CarPlay) to refresh every displayed template.
101
+ externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
119
102
  }
120
103
 
121
104
  func setFavorites(favorites: [String]) throws {
@@ -10,7 +10,6 @@ public extension HybridAudioBrowser {
10
10
 
11
11
  func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
12
12
  Promise.async {
13
- // Create player and configure on main actor
14
13
  await MainActor.run { [weak self] in
15
14
  guard let self else { return }
16
15
  // Set the category now, but activate only when playback produces output.
@@ -20,58 +19,34 @@ public extension HybridAudioBrowser {
20
19
  // and un-published first. No-op on first setup.
21
20
  destroyPlayer()
22
21
 
23
- let newEngine = AVPlayerEngine(callbacks: self, resolver: browserManager.requests)
24
- if let buffer = options.ios?.buffer { newEngine.bufferDuration = buffer }
25
- newEngine.nowPlayingUpdater.enabled = options.autoUpdateNowPlayingMetadata ?? true
26
- newEngine.nowPlayingUpdater.formatter = options.nowPlayingMetadataFormatter
27
- newEngine.nowPlayingUpdater.isOnline = networkMonitor.isOnline
28
- let newPlayer = Player(engine: newEngine)
29
- newPlayer.callbacks = self
30
- newPlayer.retry.networkMonitor = networkMonitor
22
+ let newEngine = AVPlayerEngine(
23
+ callbacks: self,
24
+ resolver: browserManager.requests,
25
+ audioSession: audioSession,
26
+ networkStatus: networkMonitor,
27
+ bufferDuration: options.ios?.buffer ?? 0,
28
+ nowPlayingEnabled: options.autoUpdateNowPlayingMetadata ?? true,
29
+ nowPlayingFormatter: options.nowPlayingMetadataFormatter,
30
+ )
31
+ let newPlayer = Player(engine: newEngine, callbacks: self, retry: RetryLoop(networkMonitor: networkMonitor))
31
32
  if let retry = options.retry { newPlayer.retry.updatePolicy(from: retry) }
32
33
  newEngine.nowPlayingUpdater.source = newPlayer
33
34
  engine = newEngine
34
35
  player = newPlayer
35
36
  audioSession.player = newPlayer
36
37
 
37
- // Connectivity fans out here, whether or not JS subscribes: the JS event, the
38
- // now-playing stall line, and reconnecting a stalled stream. The monitor mutates on main.
39
- networkMonitor.onChanged = { [weak self] isOnline in
40
- MainActor.assumeIsolated {
41
- self?.onOnlineChanged(isOnline)
42
- self?.engine?.nowPlayingUpdater.isOnline = isOnline
43
- if isOnline { self?.player?.networkDidReturn() }
44
- }
45
- }
46
-
47
- // `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
48
- // A terminal error keeps the now-playing info (never cleared on error), and next/previous
49
- // stay enabled (gated on queue position, not state). So the
50
- // session stays controllable through errors by default — the behavior Android achieves via
51
- // InterceptingPlayer is already the iOS norm.
38
+ // `options.keepSessionAliveOnError` is ignored: on iOS an error never clears the now-playing
39
+ // info, and next/previous follow the queue, not the state.
52
40
 
53
41
  // The bundled runtime options and initial state are part of the atomic launch
54
42
  // description. Stage the state first so setup options and earlier pre-setup setter
55
43
  // calls resolve by strict last-write-wins, then apply everything to the live player.
56
- if let bundled = options.options {
57
- updateOptions(options: bundled)
58
- } else {
59
- // updateOptions applies remote commands itself; without bundled options, apply the
60
- // defaults (play, pause, next, previous, seekTo) here.
61
- applyRemoteCommands()
62
- }
44
+ if let bundled = options.options { updateOptions(options: bundled) }
63
45
  if let mode = options.repeatMode { pendingRepeatMode = mode }
64
46
  if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
65
47
  applyPendingPlayerState()
66
-
67
- // Push the resolved event cadence unconditionally. `updateOptions` applies
68
- // the interval only when it *differs* from the stored one, but the store
69
- // outlives the player — so on a re-setup with unchanged options the new
70
- // player would come up with no progress timer at all.
71
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
72
- player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
73
-
74
- // Notify listeners that player is ready (e.g., CarPlay)
48
+ // The stored options outlive the player, so a re-setup applies them to the new one.
49
+ applyOptions()
75
50
  playerAndConfiguredBrowser.check()
76
51
  }
77
52
  }
@@ -95,40 +70,26 @@ public extension HybridAudioBrowser {
95
70
 
96
71
  func updateOptions(options: NativeUpdateOptions) {
97
72
  onMainActor {
98
- let previousInterval = playerOptions.progressUpdateEventInterval
99
-
100
- // Update stored options
101
73
  playerOptions.update(from: options)
102
-
103
- // Propagate the favorite flag to the browser so it can hydrate
104
- // row hearts (the `favorite` capability is the single favoriting switch).
105
- browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
106
-
107
- // Apply remote commands to player
108
- applyRemoteCommands()
109
-
110
- // Apply progress update interval if changed
111
- if playerOptions.progressUpdateEventInterval != previousInterval {
112
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
113
- }
114
-
74
+ applyOptions()
115
75
  onOptionsChanged(playerOptions.toOptions())
116
76
  playerOptionsChangedEmitter.emit(())
117
77
  }
118
78
  }
119
79
 
120
- /// Converts capabilities to remote commands and applies them to the player
80
+ /// Applies the stored options to the browser, the player and its engine. Idempotent.
121
81
  @MainActor
122
- private func applyRemoteCommands() {
123
- guard let engine else { return }
124
-
125
- let remoteCommands = playerOptions.capabilities.buildRemoteCommands(
126
- forwardJumpInterval: NSNumber(value: playerOptions.forwardJumpInterval),
127
- backwardJumpInterval: NSNumber(value: playerOptions.backwardJumpInterval),
82
+ private func applyOptions() {
83
+ // The `favorite` capability is the one favoriting switch: it also drives the row hearts.
84
+ browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
85
+ engine?.remoteCommandController.enable(
86
+ commands: playerOptions.capabilities.remoteCommands,
87
+ forwardJumpInterval: playerOptions.forwardJumpInterval,
88
+ backwardJumpInterval: playerOptions.backwardJumpInterval,
128
89
  playbackRates: playerOptions.playbackRates,
129
90
  )
130
-
131
- engine.remoteCommands = remoteCommands
91
+ player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
92
+ player?.setPlaybackIntervalEnabled(playerOptions.playbackIntervalEnabled)
132
93
  }
133
94
 
134
95
  func getOptions() throws -> Options {
@@ -137,7 +98,7 @@ public extension HybridAudioBrowser {
137
98
 
138
99
  func setPlaybackIntervalEnabled(enabled: Bool) {
139
100
  onMainActor {
140
- playbackIntervalEnabled = enabled
101
+ playerOptions.playbackIntervalEnabled = enabled
141
102
  player?.setPlaybackIntervalEnabled(enabled)
142
103
  }
143
104
  }
@@ -230,7 +230,9 @@ public extension HybridAudioBrowser {
230
230
  try player.replace(player.queue.currentIndex, updated)
231
231
  // The only emit: a favorite toggle mutates the active track in place, so
232
232
  // onPlaybackActiveTrackChanged stays transition-only.
233
- favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
233
+ let event = FavoriteChangedEvent(track: updated, favorited: favorited)
234
+ onFavoriteChanged(event)
235
+ favoriteChangedEmitter.emit(event)
234
236
  return true
235
237
  }
236
238
  }
@@ -334,64 +336,6 @@ public extension HybridAudioBrowser {
334
336
  }
335
337
 
336
338
  func openOutputPicker() throws {
337
- DispatchQueue.main.async { [weak self] in
338
- guard let self else { return }
339
- guard let windowScene = UIApplication.shared.activeWindowScene,
340
- let window = windowScene.windows.first(where: { $0.isKeyWindow }),
341
- var topController = window.rootViewController
342
- else {
343
- self.logger.error("openOutputPicker: no active window scene / root view controller")
344
- return
345
- }
346
-
347
- // Walk up to the topmost presented view controller
348
- while let presented = topController.presentedViewController {
349
- topController = presented
350
- }
351
-
352
- // AVRoutePickerView has no public API to present its picker
353
- // programmatically, so we add it to the hierarchy and tap its internal
354
- // button. Two requirements that are easy to get wrong:
355
- // 1. The view must be *visible* to the system (a `isHidden` source view
356
- // suppresses the presentation), so we keep it transparent instead.
357
- // 2. The internal button can be nested below the top-level subviews on
358
- // modern iOS, so we search recursively rather than only direct children.
359
- let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
360
- routePicker.alpha = 0.01
361
- topController.view.addSubview(routePicker)
362
-
363
- let button = Self.firstButton(in: routePicker)
364
- self.logger.notice(
365
- "openOutputPicker: hierarchy=\(Self.describeHierarchy(routePicker), privacy: .public), buttonFound=\(button != nil, privacy: .public)",
366
- )
367
-
368
- if let button {
369
- button.sendActions(for: .touchUpInside)
370
- } else {
371
- self.logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
372
- }
373
-
374
- // Keep the source view in the hierarchy long enough for the picker to
375
- // present (on iPad it anchors a popover to this view).
376
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
377
- routePicker.removeFromSuperview()
378
- }
379
- }
380
- }
381
-
382
- /// Depth-first search for the first `UIButton` inside a view tree.
383
- private static func firstButton(in view: UIView) -> UIButton? {
384
- if let button = view as? UIButton { return button }
385
- for subview in view.subviews {
386
- if let button = firstButton(in: subview) { return button }
387
- }
388
- return nil
389
- }
390
-
391
- /// Compact description of a view tree, e.g. `AVRoutePickerView[UIButton[UIImageView]]`.
392
- private static func describeHierarchy(_ view: UIView) -> String {
393
- let name = String(describing: type(of: view))
394
- let children = view.subviews.map(describeHierarchy).joined(separator: ", ")
395
- return children.isEmpty ? name : "\(name)[\(children)]"
339
+ DispatchQueue.main.async { OutputPicker.present() }
396
340
  }
397
341
  }
@@ -10,8 +10,6 @@ import os.log
10
10
  extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
11
11
  public func playerDidChangePlayback(_ playback: Playback) {
12
12
  onPlaybackChanged(playback)
13
- // The Player sets the error before this fires, so the error line renders.
14
- engine?.nowPlayingUpdater.render()
15
13
  }
16
14
 
17
15
  public func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
@@ -25,8 +23,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
25
23
  index: event.index,
26
24
  track: event.track.map(browserManager.hydrateFavorite),
27
25
  )
26
+ onPlaybackActiveTrackChanged(hydrated)
28
27
  activeTrackChangedEmitter.emit(hydrated)
29
- engine?.nowPlayingUpdater.trackChanged()
30
28
  }
31
29
 
32
30
  public func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
@@ -38,32 +36,11 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
38
36
  }
39
37
 
40
38
  public func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
41
- // Intent to play again is the earliest cancel signal for a pending session release.
42
- if playWhenReady { audioSession.cancelRelease() }
43
39
  onPlaybackPlayWhenReadyChanged(PlaybackPlayWhenReadyChangedEvent(playWhenReady: playWhenReady))
44
40
  }
45
41
 
46
42
  public func playerDidChangePlayingState(_ state: PlayingState) {
47
43
  onPlaybackPlayingState(state)
48
- // Lazily activate the audio session the moment playback actually produces — or is buffering to
49
- // produce — output. This (not setup) is where we grab the session, so launching the app never
50
- // interrupts other apps' audio; the interruption only happens when the user truly starts
51
- // playback, which is correct. Keyed off play *state*, not `playWhenReady`, because the intent
52
- // can be set at setup (before any track) while output only begins here. Idempotent:
53
- // `setActive(true)` is a no-op when the session is already active.
54
- if state.playing || state.buffering {
55
- // The now-playing session's activation request at link time predates this
56
- // (the audio session was still inactive, and the system may have declined);
57
- // re-request once the audio session is active, so the surface can appear even if
58
- // playback never produces audio (first-ever load failing on a dead stream).
59
- audioSession.activate { [weak self] in self?.engine?.reactivateNowPlayingSession() }
60
- }
61
- // A stall starting or recovering may not change the Player state.
62
- engine?.nowPlayingUpdater.render()
63
- }
64
-
65
- public func playerShouldReleaseSession() {
66
- audioSession.scheduleRelease()
67
44
  }
68
45
 
69
46
  public func playerDidEndQueue(_ event: PlaybackQueueEndedEvent) {
@@ -71,16 +48,18 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
71
48
  }
72
49
 
73
50
  public func playerDidChangeQueue(_ tracks: [Track]) {
51
+ onPlaybackQueueChanged(tracks)
74
52
  queueChangedEmitter.emit(tracks)
75
53
  }
76
54
 
77
55
  public func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
56
+ onPlaybackRepeatModeChanged(event)
78
57
  repeatModeChangedEmitter.emit(event)
79
58
  }
80
59
 
81
60
  public func playerDidChangeShuffleEnabled(_ enabled: Bool) {
82
- shuffleChangedEmitter.emit(enabled)
83
61
  onPlaybackShuffleModeChanged(enabled)
62
+ shuffleChangedEmitter.emit(enabled)
84
63
  }
85
64
 
86
65
  public func playerDidError(_ event: PlaybackErrorEvent) {
@@ -100,13 +79,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
100
79
  onTrackMetadata(metadata)
101
80
  }
102
81
 
103
- public func playerDidReceiveTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
104
- for group in groups {
105
- if let metadata = TimedMetadata.from(items: group.items, icyCharset: player?.currentTrack?.icyCharset) {
106
- onTimedMetadata(metadata)
107
- engine?.nowPlayingUpdater.receivedTimedMetadata(metadata)
108
- }
109
- }
82
+ public func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
83
+ onTimedMetadata(metadata)
110
84
  }
111
85
 
112
86
  public func playerDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
@@ -126,10 +100,6 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
126
100
  dispatchRemote(handler: handleRemoteStop, report: onRemoteStop) { player?.stop() }
127
101
  }
128
102
 
129
- public func remotePlayPause() {
130
- if player?.playWhenReady == true { remotePause() } else { remotePlay() }
131
- }
132
-
133
103
  public func remoteNext() {
134
104
  dispatchRemote(handler: handleRemoteNext, report: onRemoteNext) { player?.next() }
135
105
  }
@@ -174,12 +144,10 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
174
144
  }
175
145
 
176
146
  public func remoteChangeRepeatMode(mode: RepeatMode) {
177
- // Apply the repeat mode change from CarPlay/lock screen
178
147
  try? setRepeatMode(mode: mode)
179
148
  }
180
149
 
181
150
  public func remoteChangeShuffleMode(enabled: Bool) {
182
- // Apply the shuffle mode change from CarPlay/lock screen
183
151
  try? setShuffleEnabled(enabled: enabled)
184
152
  }
185
153
 
@@ -8,7 +8,7 @@ import os.log
8
8
  @MainActor let playerAndConfiguredBrowser = ReadyValue<(HybridAudioBrowser, Player)> {
9
9
  guard let browser = HybridAudioBrowser.shared,
10
10
  browser.browserManager.isConfigured,
11
- let player = browser.getPlayer() else { return nil }
11
+ let player = browser.player else { return nil }
12
12
  return (browser, player)
13
13
  }
14
14
 
@@ -34,14 +34,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
34
34
  /// silent.
35
35
  static let instanceChangedEmitter = Emitter<Void>()
36
36
 
37
- /// Installed once, on first instance creation. React Native posts this
38
- /// notification (from `RCTReloadCommand`) when a JS reload — dev or
39
- /// OTA-update restart — is triggered, *before* the old runtime is destroyed:
40
- /// the earliest signal that this instance's JS callbacks are about to go
41
- /// stale. `init` of the replacement instance drops them too, but only after
42
- /// the new runtime is up; this observer closes the window in between.
43
- /// Observed by name so the library needs no React-Core dependency (and
44
- /// stays inert under SPM tests, where nothing posts it).
37
+ /// React Native posts this notification when a JS reload is triggered, before the old runtime
38
+ /// is destroyed: the earliest sign this instance's JS callbacks go stale. The replacement's
39
+ /// `init` drops them too, but only once the new runtime is up. Observed by name, so the library
40
+ /// needs no React-Core dependency.
45
41
  @MainActor private static let reloadObserver: NSObjectProtocol = NotificationCenter.default.addObserver(
46
42
  forName: Notification.Name("RCTTriggerReloadCommandNotification"),
47
43
  object: nil,
@@ -71,29 +67,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
71
67
  @MainActor var pendingRepeatMode: RepeatMode?
72
68
  @MainActor let playerOptions = PlayerUpdateOptions()
73
69
 
74
- /// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
75
- @MainActor var playbackRates: [Double] { playerOptions.playbackRates }
76
- @MainActor var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
77
- @MainActor var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
78
- playerOptions.carPlayNowPlayingButtons
79
- }
80
-
81
- @MainActor var carPlayPlayingIndicatorLocation: CarPlayPlayingIndicatorLocation {
82
- playerOptions.carPlayPlayingIndicatorLocation
83
- }
84
-
85
70
  @MainActor lazy var navigationError = NavigationErrorState(
86
- onErrorChanged: { [weak self] in self?.navigationErrorEmitter.emit(NavigationErrorEvent(error: $0)) },
71
+ onErrorChanged: { [weak self] in self?.onNavigationError(NavigationErrorEvent(error: $0)) },
87
72
  onFormattedChanged: { [weak self] in self?.onFormattedNavigationError($0) },
88
73
  )
89
74
 
90
- // MARK: - Multi-Listener Emitters
75
+ // MARK: - Emitters for Native Listeners
91
76
 
92
- /// Emitters allow multiple listeners for each event type, avoiding callback hijacking
93
77
  public let favoriteChangedEmitter = Emitter<FavoriteChangedEvent>()
94
78
  public let activeTrackChangedEmitter = Emitter<PlaybackActiveTrackChangedEvent>()
95
79
  public let queueChangedEmitter = Emitter<[Track]>()
96
- public let navigationErrorEmitter = Emitter<NavigationErrorEvent>()
97
80
  public let repeatModeChangedEmitter = Emitter<RepeatModeChangedEvent>()
98
81
  public let shuffleChangedEmitter = Emitter<Bool>()
99
82
  public let externalContentChangedEmitter = Emitter<String>()
@@ -111,17 +94,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
111
94
 
112
95
  /// Runs a closure on MainActor synchronously. Safe for non-Sendable return types
113
96
  /// because DispatchQueue.main.sync blocks the caller — no concurrent access window.
114
- func onMainActor<T>(_ work: @MainActor () -> T) -> T {
115
- if Thread.isMainThread {
116
- MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
117
- } else {
118
- DispatchQueue.main.sync {
119
- MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
120
- }
121
- }
122
- }
123
-
124
- func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
97
+ func onMainActor<T>(_ work: @MainActor () throws -> T) rethrows -> T {
125
98
  if Thread.isMainThread {
126
99
  try MainActor.assumeIsolated { try UncheckedSendableBox(value: work()) }.value
127
100
  } else {
@@ -159,14 +132,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
159
132
  let configuredPath = configuration.path
160
133
  onMainActor { browserManager.config = BrowserConfig(from: configuration) }
161
134
 
162
- // Query tabs and navigate to initial path after config is set (matches Kotlin behavior)
163
135
  Task { @MainActor in
164
- // Notify waiting listeners (e.g., CarPlay cold start)
165
136
  playerAndConfiguredBrowser.check()
166
137
  }
167
138
  Task {
168
139
  let tabs = try? await browserManager.queryTabs()
169
- // Navigate to configured path, first tab, or "/"
170
140
  navigate(configuredPath ?? tabs?.first?.path ?? "/", clearError: true)
171
141
  }
172
142
  }
@@ -258,9 +228,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
258
228
  public var onNowPlayingChanged: (NowPlayingMetadata) -> Void = { _ in }
259
229
  public var onOnlineChanged: (Bool) -> Void = { _ in } {
260
230
  didSet {
261
- // The monitor->JS bridge is wired centrally in setupPlayer (so it doesn't depend on a JS
262
- // subscription and can also drive the now-playing re-render + player reconnect). Here we only
263
- // push the current state to a newly-attached subscriber.
231
+ // A new subscriber hears the current state at once.
264
232
  onOnlineChanged(networkMonitor.isOnline)
265
233
  }
266
234
  }
@@ -280,11 +248,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
280
248
  }
281
249
  }
282
250
 
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
-
288
251
  // MARK: - Initialization
289
252
 
290
253
  override public init() {
@@ -302,6 +265,15 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
302
265
  HybridAudioBrowser.shared = self
303
266
  audioSession.outputChanged.addListener { [weak self] in self?.onOutputChanged($0) }
304
267
  audioSession.volumeChanged.addListener { [weak self] in self?.onSystemVolumeChanged($0) }
268
+ // From the first setup on, whether or not JS subscribes: JS, then the player.
269
+ networkMonitor.changed.addListener { [weak self] isOnline in
270
+ guard let self else { return }
271
+ MainActor.assumeIsolated {
272
+ guard let player = self.player else { return }
273
+ self.onOnlineChanged(isOnline)
274
+ player.networkStatusDidChange(isOnline: isOnline)
275
+ }
276
+ }
305
277
  browseGate.resolve = { [weak self] request in
306
278
  guard let self else { throw CancellationError() }
307
279
  return try await resolveGate(request).await().await()
@@ -312,24 +284,18 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
312
284
  // controller is subscribed to a dead instance's emitters.
313
285
  HybridAudioBrowser.instanceChangedEmitter.emit(())
314
286
  }
315
- setupEmitterToNitroForwarding()
287
+ forwardBrowserEventsToJS()
316
288
  }
317
289
 
318
290
  /// Drops every JS-provided callback that native code can invoke on its own
319
291
  /// initiative (browse resolvers, `handleTrackLoad`, `resolveAlbumPath`,
320
292
  /// `formatNavigationError`, `resolveGate`, the now-playing formatter).
321
293
  ///
322
- /// Called on the *previous* instance when a new one takes over: its JS runtime
323
- /// is gone, and invoking a callback from a dead runtime aborts the process —
324
- /// Nitro's `AsyncJSCallback` throws on a destroyed Dispatcher, and the throw
325
- /// crosses the generated bridge wrapper's `noexcept` boundary. Native code
326
- /// (e.g. a CarPlay controller still bound to this instance mid-reload) keeps
327
- /// working off the data-only config with built-in fallbacks; `resolveGate`
328
- /// reverts to the fail-closed default it documents for exactly this window.
329
- /// One-shot: on a normal reload both triggers hit the same dying instance
330
- /// (the reload notification, then the replacement's `init`), and the second
331
- /// strip would re-fire the config-setter cascade — another `configChanged`
332
- /// → CarPlay now-playing button rebuild — for nothing.
294
+ /// Called on the previous instance when a new one takes over: invoking a callback of a dead
295
+ /// runtime aborts the process (Nitro throws across a `noexcept` boundary). Native code still
296
+ /// bound to this instance (CarPlay mid-reload) keeps working off the data-only config;
297
+ /// `resolveGate` reverts to its fail-closed default. One-shot: both triggers reach the same
298
+ /// dying instance, and a second strip would re-run the config setter's cascade.
333
299
  @MainActor private var didDropJSCallbacks = false
334
300
 
335
301
  @MainActor
@@ -348,61 +314,32 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
348
314
  }
349
315
 
350
316
  deinit {
351
- // Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
317
+ // Safety net: the AVPlayer must not outlive this instance. `self` can't reach the main
318
+ // actor from here, so the teardown takes what it releases.
352
319
  let player = self.player, engine = self.engine
353
- if Thread.isMainThread {
354
- MainActor.assumeIsolated {
355
- player?.clear()
356
- engine?.destroy()
357
- }
358
- } else {
359
- DispatchQueue.main.async {
360
- MainActor.assumeIsolated {
361
- player?.clear()
362
- engine?.destroy()
363
- }
364
- }
365
- }
366
- }
367
-
368
- /// Returns the player, if setup has been called.
369
- func getPlayer() -> Player? {
370
- player
320
+ let tearDown: @Sendable () -> Void = { MainActor.assumeIsolated { Self.destroy(player, engine) } }
321
+ if Thread.isMainThread { tearDown() } else { DispatchQueue.main.async(execute: tearDown) }
371
322
  }
372
323
 
373
324
  /// Stops the player and releases its media stack: the AVPlayer and the now-playing session
374
325
  /// outlive a dropped reference otherwise.
375
326
  @MainActor
376
327
  func destroyPlayer() {
328
+ Self.destroy(player, engine)
329
+ }
330
+
331
+ @MainActor
332
+ private static func destroy(_ player: Player?, _ engine: AVPlayerEngine?) {
377
333
  player?.clear()
378
334
  engine?.destroy()
379
335
  }
380
336
 
381
- /// Sets up automatic forwarding from emitters to Nitro callbacks
382
- private func setupEmitterToNitroForwarding() {
337
+ /// Forwards the browser's events to JS. The bridge's own events call JS where they are
338
+ /// emitted; their emitters are for native listeners (CarPlay).
339
+ private func forwardBrowserEventsToJS() {
383
340
  browserManager.pathChanged.addListener { [weak self] in self?.onPathChanged($0) }
384
341
  browserManager.tabsChanged.addListener { [weak self] in self?.onTabsChanged($0) }
385
342
  browserManager.contentChanged.addListener { [weak self] in self?.onContentChanged($0) }
386
-
387
- favoriteChangedEmitter.addListener { [weak self] event in
388
- self?.onFavoriteChanged(event)
389
- }
390
-
391
- activeTrackChangedEmitter.addListener { [weak self] event in
392
- self?.onPlaybackActiveTrackChanged(event)
393
- }
394
-
395
- queueChangedEmitter.addListener { [weak self] tracks in
396
- self?.onPlaybackQueueChanged(tracks)
397
- }
398
-
399
- navigationErrorEmitter.addListener { [weak self] event in
400
- self?.onNavigationError(event)
401
- }
402
-
403
- repeatModeChangedEmitter.addListener { [weak self] event in
404
- self?.onPlaybackRepeatModeChanged(event)
405
- }
406
343
  }
407
344
  }
408
345