react-native-audio-browser 0.2.0-next.7 → 0.2.0-next.9

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 (165) hide show
  1. package/android/build.gradle +7 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +33 -8
  3. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +46 -0
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  6. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  7. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  8. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  9. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  10. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  11. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  12. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +68 -0
  13. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
  14. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  15. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  16. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  17. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  18. package/ios/Browser/BrowserManager.swift +2 -2
  19. package/ios/Browser/SearchDrillIn.swift +1 -1
  20. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  21. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  22. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  23. package/ios/HybridAudioBrowser.swift +86 -55
  24. package/ios/Model/RemoteCommand.swift +18 -0
  25. package/ios/Model/TrackPlayerError.swift +2 -2
  26. package/ios/Option/SessionCategories.swift +3 -2
  27. package/ios/Player/MediaIntentCriteria.swift +4 -4
  28. package/ios/Player/PlaybackErrorHandler.swift +2 -2
  29. package/ios/Player/RetryManager.swift +2 -1
  30. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  31. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  32. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  33. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  34. package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
  35. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  36. package/ios/Tests/RemoteCommandTests.swift +67 -0
  37. package/ios/Tests/SectionScopeTests.swift +2 -2
  38. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  39. package/ios/TrackPlayer.swift +1 -1
  40. package/ios/TrackPlayerCallbacks.swift +5 -38
  41. package/lib/commonjs/AudioBrowser.js +0 -11
  42. package/lib/commonjs/AudioBrowser.js.map +1 -1
  43. package/lib/commonjs/features/carConnection.js +1 -1
  44. package/lib/commonjs/features/equalizer.js +1 -1
  45. package/lib/commonjs/features/errors.js +3 -3
  46. package/lib/commonjs/features/favorites.js +1 -1
  47. package/lib/commonjs/features/gate.js +1 -1
  48. package/lib/commonjs/features/metadata.js +3 -3
  49. package/lib/commonjs/features/network.js +1 -1
  50. package/lib/commonjs/features/nowPlaying.js +1 -1
  51. package/lib/commonjs/features/output.js +1 -1
  52. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  53. package/lib/commonjs/features/playback/playing.js +1 -1
  54. package/lib/commonjs/features/playback/progress.js +1 -1
  55. package/lib/commonjs/features/playback/state.js +1 -1
  56. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  57. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  58. package/lib/commonjs/features/playback/volume.js +1 -1
  59. package/lib/commonjs/features/player/options.js +3 -19
  60. package/lib/commonjs/features/player/options.js.map +1 -1
  61. package/lib/commonjs/features/player/setup.js +2 -2
  62. package/lib/commonjs/features/player/setup.js.map +1 -1
  63. package/lib/commonjs/features/player/validateOptions.js +24 -0
  64. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  65. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  66. package/lib/commonjs/features/queue/queue.js +2 -2
  67. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  68. package/lib/commonjs/features/queue/shuffle.js +1 -1
  69. package/lib/commonjs/features/remoteControls.js +13 -92
  70. package/lib/commonjs/features/remoteControls.js.map +1 -1
  71. package/lib/commonjs/features/sleepTimer.js +1 -1
  72. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  73. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  74. package/lib/module/AudioBrowser.js +4 -1
  75. package/lib/module/AudioBrowser.js.map +1 -1
  76. package/lib/module/features/carConnection.js +1 -1
  77. package/lib/module/features/equalizer.js +1 -1
  78. package/lib/module/features/errors.js +3 -3
  79. package/lib/module/features/favorites.js +1 -1
  80. package/lib/module/features/gate.js +1 -1
  81. package/lib/module/features/metadata.js +3 -3
  82. package/lib/module/features/network.js +1 -1
  83. package/lib/module/features/nowPlaying.js +1 -1
  84. package/lib/module/features/output.js +1 -1
  85. package/lib/module/features/playback/playWhenReady.js +1 -1
  86. package/lib/module/features/playback/playing.js +1 -1
  87. package/lib/module/features/playback/progress.js +1 -1
  88. package/lib/module/features/playback/state.js +1 -1
  89. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  90. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  91. package/lib/module/features/playback/volume.js +1 -1
  92. package/lib/module/features/player/options.js +2 -17
  93. package/lib/module/features/player/options.js.map +1 -1
  94. package/lib/module/features/player/setup.js +1 -1
  95. package/lib/module/features/player/setup.js.map +1 -1
  96. package/lib/module/features/player/validateOptions.js +20 -0
  97. package/lib/module/features/player/validateOptions.js.map +1 -0
  98. package/lib/module/features/queue/activeTrack.js +1 -1
  99. package/lib/module/features/queue/queue.js +2 -2
  100. package/lib/module/features/queue/repeatMode.js +1 -1
  101. package/lib/module/features/queue/shuffle.js +1 -1
  102. package/lib/module/features/remoteControls.js +13 -92
  103. package/lib/module/features/remoteControls.js.map +1 -1
  104. package/lib/module/features/sleepTimer.js +1 -1
  105. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  106. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  107. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  108. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  109. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  110. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  111. package/lib/typescript/src/features/errors.d.ts +3 -3
  112. package/lib/typescript/src/features/favorites.d.ts +1 -1
  113. package/lib/typescript/src/features/gate.d.ts +3 -2
  114. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  115. package/lib/typescript/src/features/metadata.d.ts +3 -3
  116. package/lib/typescript/src/features/network.d.ts +1 -1
  117. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  118. package/lib/typescript/src/features/output.d.ts +1 -1
  119. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  120. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  121. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  122. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  123. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  124. package/lib/typescript/src/features/player/options.d.ts +1 -1
  125. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  126. package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
  127. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  128. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  129. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  130. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  131. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  132. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  133. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  134. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  135. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  136. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  137. package/package.json +3 -1
  138. package/src/AudioBrowser.ts +4 -1
  139. package/src/features/carConnection.ts +1 -1
  140. package/src/features/equalizer.ts +1 -1
  141. package/src/features/errors.ts +3 -3
  142. package/src/features/favorites.ts +1 -1
  143. package/src/features/gate.ts +1 -1
  144. package/src/features/metadata.ts +3 -3
  145. package/src/features/network.ts +1 -1
  146. package/src/features/nowPlaying.ts +1 -1
  147. package/src/features/output.ts +1 -1
  148. package/src/features/playback/playWhenReady.ts +1 -1
  149. package/src/features/playback/playing.ts +1 -1
  150. package/src/features/playback/progress.ts +1 -1
  151. package/src/features/playback/state.ts +1 -1
  152. package/src/features/playback/trackPlaybackTime.test.ts +20 -6
  153. package/src/features/playback/trackPlaybackTime.ts +0 -5
  154. package/src/features/playback/volume.ts +1 -1
  155. package/src/features/player/options.ts +2 -20
  156. package/src/features/player/setup.ts +1 -1
  157. package/src/features/player/validateOptions.ts +22 -0
  158. package/src/features/queue/activeTrack.ts +1 -1
  159. package/src/features/queue/queue.ts +2 -2
  160. package/src/features/queue/repeatMode.ts +1 -1
  161. package/src/features/queue/shuffle.ts +1 -1
  162. package/src/features/remoteControls.ts +13 -92
  163. package/src/features/sleepTimer.ts +1 -1
  164. package/src/public-surface.test.ts +123 -0
  165. package/src/utils/LazyNativeEmitter.ts +9 -6
@@ -16,6 +16,10 @@ import os.log
16
16
  /// Only safe when the caller blocks until the MainActor work completes (DispatchQueue.main.sync).
17
17
  private struct UncheckedSendableBox<T>: @unchecked Sendable { let value: T }
18
18
 
19
+ /// `@unchecked Sendable` because it must be: the spec's requirements are all
20
+ /// nonisolated and Nitro calls them synchronously on the JS thread, so the class
21
+ /// can't be `@MainActor`. Isolation is therefore per-property, and `@unchecked`
22
+ /// means the compiler won't tell you when a new one needs it.
19
23
  public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
20
24
  private let logger = Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
21
25
 
@@ -32,6 +36,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
32
36
 
33
37
  // MARK: - Private Properties
34
38
 
39
+ /// Main-confined by convention, not annotation: `deinit` reads it as a
40
+ /// safety net and can't hop. Reach it from `onMainActor` / `@MainActor`.
35
41
  private var player: TrackPlayer?
36
42
  private let networkMonitor = NetworkMonitor()
37
43
  private let playbackStateStore = PlaybackStateStore()
@@ -43,27 +49,31 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
43
49
  private var mediaServicesResetObserver: NSObjectProtocol?
44
50
  /// Re-applies our resolved audio-session category. Captured at setup so a
45
51
  /// media-services reset (which clears the category) can restore it.
46
- private var applyAudioSessionCategory: () -> Void = {}
47
- private var nowPlayingOverride: NowPlayingUpdate?
52
+ @MainActor private var applyAudioSessionCategory: () -> Void = {}
53
+ @MainActor private var nowPlayingOverride: NowPlayingUpdate?
48
54
  /// When false, the now-playing surface uses the raw track fields (override + formatter ignored).
49
- private var nowPlayingMetadataEnabled = true
55
+ @MainActor private var nowPlayingMetadataEnabled = true
50
56
  /// Customizes the now-playing title/subtitle from the track + live timed metadata + playback state.
51
- private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
52
- /// Keep the media session controllable through a terminal error (see `keepSessionAliveOnError`).
53
- private var keepSessionAliveOnError = true
57
+ @MainActor private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
54
58
  /// Initial player state staged before the player exists — from setup options or the imperative
55
59
  /// setters called pre-setup. Strict last-write-wins; consumed when the player comes up.
56
- private var pendingPlayWhenReady: Bool?
57
- private var pendingRepeatMode: RepeatMode?
60
+ @MainActor private var pendingPlayWhenReady: Bool?
61
+ @MainActor private var pendingRepeatMode: RepeatMode?
58
62
  /// Latest live timed (ICY/ID3) metadata, passed to the formatter. Cleared on track change.
59
- private var latestTimedMetadata: TimedMetadata?
60
- private let playerOptions = PlayerUpdateOptions()
63
+ @MainActor private var latestTimedMetadata: TimedMetadata?
64
+ @MainActor private let playerOptions = PlayerUpdateOptions()
61
65
 
62
66
  /// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
63
- var playbackRates: [Double] { playerOptions.playbackRates }
64
- var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
65
- var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] { playerOptions.carPlayNowPlayingButtons }
66
- private var lastNavigationError: NavigationError? {
67
+ @MainActor var playbackRates: [Double] { playerOptions.playbackRates }
68
+ @MainActor var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
69
+ @MainActor var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
70
+ playerOptions.carPlayNowPlayingButtons
71
+ }
72
+
73
+ /// Written from the JS thread, the cooperative pool and MainActor, with a
74
+ /// `didSet` that calls back into Nitro. MainActor-isolated so the compiler
75
+ /// requires an `onMainActor` hop at every access.
76
+ @MainActor private var lastNavigationError: NavigationError? {
67
77
  didSet {
68
78
  // Skip if both nil (no real change)
69
79
  guard oldValue != nil || lastNavigationError != nil else { return }
@@ -71,7 +81,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
71
81
  }
72
82
  }
73
83
 
74
- private var lastFormattedNavigationError: FormattedNavigationError? {
84
+ /// Isolated for the same reason as ``lastNavigationError``.
85
+ @MainActor private var lastFormattedNavigationError: FormattedNavigationError? {
75
86
  didSet {
76
87
  // Skip if both nil (no real change)
77
88
  guard oldValue != nil || lastFormattedNavigationError != nil else { return }
@@ -156,6 +167,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
156
167
  androidControllerOfflineError: nil, carPlayLoadingTitle: nil, resolveAlbumUrl: nil, formatNavigationError: nil,
157
168
  ) {
158
169
  didSet {
170
+ // Copied here, on the setter's thread: the Task below outlives this call,
171
+ // and Nitro can reassign `configuration` — a C++ struct holding
172
+ // std::string/std::function — from the JS thread while it runs.
173
+ let configuredPath = configuration.path
159
174
  onMainActor { browserManager.config = BrowserConfig(from: configuration) }
160
175
 
161
176
  // Query tabs and navigate to initial path after config is set (matches Kotlin behavior)
@@ -166,12 +181,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
166
181
  Task {
167
182
  let tabs = try? await browserManager.queryTabs()
168
183
  // Navigate to configured path, first tab, or "/"
169
- let initialPath = configuration.path ?? tabs?.first?.url ?? "/"
184
+ let initialPath = configuredPath ?? tabs?.first?.url ?? "/"
170
185
  // Clear error before navigation (matches Kotlin clearNavigationError())
171
- await MainActor.run {
172
- lastNavigationError = nil
173
- lastFormattedNavigationError = nil
174
- }
186
+ clearNavigationError()
175
187
  do {
176
188
  try await browserManager.navigate(initialPath)
177
189
  } catch {
@@ -370,7 +382,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
370
382
  private func handleNavigationError(_ error: Error, path: String) {
371
383
  let navError = NavigationError.from(error)
372
384
 
373
- lastNavigationError = navError
385
+ onMainActor { lastNavigationError = navError }
374
386
 
375
387
  // Format the error (async if using JS callback, sync for defaults)
376
388
  let defaultFormatted = navError.defaultFormatted()
@@ -380,27 +392,40 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
380
392
  // The result callbacks are @Sendable: a plain closure would inherit MainActor
381
393
  // isolation from this block, and Nitro resolves its promises synchronously on the
382
394
  // JS thread — Swift 6.2's dynamic isolation check then traps on closure entry.
383
- // The property is JS-thread-touched elsewhere too, so no hop back is needed.
395
+ // They therefore hop back explicitly, like every other writer.
384
396
  DispatchQueue.main.async { [weak self] in
385
397
  formatter(params)
386
398
  .then { @Sendable [weak self] customDisplay in
387
- self?.lastFormattedNavigationError = customDisplay ?? defaultFormatted
399
+ self?.setFormattedNavigationError(customDisplay ?? defaultFormatted)
388
400
  }
389
401
  .catch { @Sendable [weak self] _ in
390
- self?.lastFormattedNavigationError = defaultFormatted
402
+ self?.setFormattedNavigationError(defaultFormatted)
391
403
  }
392
404
  }
393
405
  } else {
394
- lastFormattedNavigationError = defaultFormatted
406
+ setFormattedNavigationError(defaultFormatted)
407
+ }
408
+ }
409
+
410
+ /// Callable from any thread — the Nitro promise callbacks below resolve on
411
+ /// the JS thread.
412
+ private func setFormattedNavigationError(_ formatted: FormattedNavigationError?) {
413
+ onMainActor { lastFormattedNavigationError = formatted }
414
+ }
415
+
416
+ /// Synchronous on purpose: the `didSet`s notify JS, which has to happen
417
+ /// before the navigation that may set them again.
418
+ private func clearNavigationError() {
419
+ onMainActor {
420
+ lastNavigationError = nil
421
+ lastFormattedNavigationError = nil
395
422
  }
396
423
  }
397
424
 
398
425
  // MARK: - Browser Methods
399
426
 
400
427
  public func navigatePath(path: String) throws {
401
- // Clear error synchronously before starting navigation (didSet notifies JS)
402
- lastNavigationError = nil
403
- lastFormattedNavigationError = nil
428
+ clearNavigationError()
404
429
  Task {
405
430
  do {
406
431
  try await browserManager.navigate(path)
@@ -411,7 +436,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
411
436
  }
412
437
 
413
438
  public func navigateTrack(track: Track) throws {
414
- Task {
439
+ // @MainActor: `player` is main-confined, and `trackSelector.select` is
440
+ // MainActor anyway — a bare Task read the reference off the cooperative
441
+ // pool, racing the assignment in `setupPlayer`.
442
+ Task { @MainActor in
415
443
  guard let player else { return }
416
444
  let result = await trackSelector.select(track: track, player: player)
417
445
  switch result {
@@ -445,9 +473,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
445
473
  }
446
474
 
447
475
  private func navigateToBrowsableUrl(_ url: String) {
448
- // Clear error synchronously before starting navigation (didSet notifies JS)
449
- lastNavigationError = nil
450
- lastFormattedNavigationError = nil
476
+ clearNavigationError()
451
477
  Task {
452
478
  do {
453
479
  try await browserManager.navigate(url)
@@ -470,11 +496,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
470
496
  }
471
497
 
472
498
  public func getNavigationError() throws -> NavigationError? {
473
- lastNavigationError
499
+ onMainActor { lastNavigationError }
474
500
  }
475
501
 
476
502
  public func getFormattedNavigationError() throws -> FormattedNavigationError? {
477
- lastFormattedNavigationError
503
+ onMainActor { lastFormattedNavigationError }
478
504
  }
479
505
 
480
506
  /// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
@@ -601,7 +627,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
601
627
  // built-in so the gate page still renders rather than leaking content.
602
628
  return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate)
603
629
  }
604
- guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
630
+ guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
605
631
  return GateOutcome(gated: true, chrome: decision.gate ?? defaultChrome ?? Self.builtInGate)
606
632
  }
607
633
 
@@ -649,7 +675,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
649
675
  category: AVAudioSession.Category,
650
676
  mode: AVAudioSession.Mode,
651
677
  policy: AVAudioSession.RouteSharingPolicy,
652
- options: AVAudioSession.CategoryOptions
678
+ options: AVAudioSession.CategoryOptions,
653
679
  ) = options.ios?.resolveAudioSessionConfig()
654
680
  ?? (category: .playback, mode: .default, policy: .default, options: [])
655
681
  // Capture category application once so a media-services reset (which
@@ -733,16 +759,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
733
759
  self?.onSleepTimerChanged(state)
734
760
  }
735
761
 
736
- // Now-playing: whether to manage metadata, the optional formatter that owns the rendered
737
- // lines, and whether to keep the session alive through a terminal error.
762
+ // Now-playing: whether to manage metadata, and the optional formatter that owns the
763
+ // rendered lines.
738
764
  nowPlayingMetadataEnabled = options.autoUpdateNowPlayingMetadata ?? true
739
765
  nowPlayingMetadataFormatter = options.nowPlayingMetadataFormatter
740
- // Stored for API symmetry, but iOS needs no masking: a terminal error resolves the playback
741
- // state to *paused* (PlaybackCoordinator.applySideEffects), the now-playing info is retained
742
- // (never cleared on error), and next/previous stay enabled (gated on queue position, not
743
- // state). So the session stays controllable through errors by default — the behavior Android
744
- // achieves via InterceptingPlayer is already the iOS norm.
745
- keepSessionAliveOnError = options.keepSessionAliveOnError ?? true
766
+ // `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
767
+ // A terminal error resolves the playback state to *paused*
768
+ // (PlaybackCoordinator.applySideEffects), the now-playing info is retained (never cleared
769
+ // on error), and next/previous stay enabled (gated on queue position, not state). So the
770
+ // session stays controllable through errors by default — the behavior Android achieves via
771
+ // InterceptingPlayer is already the iOS norm.
746
772
 
747
773
  // The bundled runtime options and initial state are part of the atomic launch
748
774
  // description. Stage the state first so setup options and earlier pre-setup setter
@@ -819,7 +845,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
819
845
  }
820
846
 
821
847
  public func getOptions() throws -> Options {
822
- playerOptions.toOptions()
848
+ onMainActor { playerOptions.toOptions() }
823
849
  }
824
850
 
825
851
  public func setPlaybackIntervalEnabled(enabled: Bool) {
@@ -1121,11 +1147,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1121
1147
  /// metadata can't stomp it mid-flash. Reverted by a NATIVE timer — JS
1122
1148
  /// timers pause with a backgrounded host on Android, and the lock screen
1123
1149
  /// is exactly the backgrounded case — and cleared early on track change.
1124
- private var nowPlayingFlash: NowPlayingUpdate?
1125
- private var nowPlayingFlashRevert: DispatchWorkItem?
1150
+ @MainActor private var nowPlayingFlash: NowPlayingUpdate?
1151
+ @MainActor private var nowPlayingFlashRevert: DispatchWorkItem?
1126
1152
 
1127
1153
  /// Pending debounced audio-session release (see `playerShouldReleaseSession`).
1128
- private var sessionReleaseWork: DispatchWorkItem?
1154
+ @MainActor private var sessionReleaseWork: DispatchWorkItem?
1129
1155
 
1130
1156
  public func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
1131
1157
  onMainActor {
@@ -1151,6 +1177,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1151
1177
  }
1152
1178
  }
1153
1179
 
1180
+ @MainActor
1154
1181
  private func cancelNowPlayingFlash() {
1155
1182
  nowPlayingFlashRevert?.cancel()
1156
1183
  nowPlayingFlashRevert = nil
@@ -1354,8 +1381,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1354
1381
  guard let self else { return }
1355
1382
  // Re-apply our category (the reset cleared it), then have the player
1356
1383
  // recreate itself and reconnect the current stream.
1357
- self.applyAudioSessionCategory()
1358
- self.onMainActor { self.player?.handleMediaServicesReset() }
1384
+ self.onMainActor {
1385
+ self.applyAudioSessionCategory()
1386
+ self.player?.handleMediaServicesReset()
1387
+ }
1359
1388
  }
1360
1389
  }
1361
1390
 
@@ -1394,7 +1423,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1394
1423
 
1395
1424
  public func supportsOutputSwitcher() throws -> Bool {
1396
1425
  // iOS can always present the system route picker (AVRoutePickerView).
1397
- return true
1426
+ true
1398
1427
  }
1399
1428
 
1400
1429
  public func openOutputPicker() throws {
@@ -1586,6 +1615,7 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1586
1615
  DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
1587
1616
  }
1588
1617
 
1618
+ @MainActor
1589
1619
  private func cancelSessionRelease() {
1590
1620
  sessionReleaseWork?.cancel()
1591
1621
  sessionReleaseWork = nil
@@ -1786,7 +1816,8 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1786
1816
  // URL (parent container → siblings + selected index). Fall back to the single
1787
1817
  // track when the url isn't contextual or expansion fails.
1788
1818
  if let url = state.track.url,
1789
- let expanded = try? await browser.browserManager.expandQueueFromContextualUrl(url) {
1819
+ let expanded = try? await browser.browserManager.expandQueueFromContextualUrl(url)
1820
+ {
1790
1821
  player.setQueue(expanded.tracks, initialIndex: expanded.selectedIndex, startPositionMs: startMs, playWhenReady: true)
1791
1822
  } else {
1792
1823
  player.setQueue([track], initialIndex: 0, startPositionMs: startMs, playWhenReady: true)
@@ -1795,7 +1826,7 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1795
1826
  completion(true)
1796
1827
  } else {
1797
1828
  browser.logger.error("resume: nothing playing and nothing persisted → no-op")
1798
- completion(false) // nothing playing and nothing persisted
1829
+ completion(false) // nothing playing and nothing persisted
1799
1830
  }
1800
1831
  return
1801
1832
  }
@@ -1812,14 +1843,14 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1812
1843
  title: criteria.title,
1813
1844
  playlist: criteria.playlist,
1814
1845
  // .currentlyPlaying can't reach here — isResume routed it to resume.
1815
- reference: criteria.reference == .my ? .my : .unknown
1846
+ reference: criteria.reference == .my ? .my : .unknown,
1816
1847
  )
1817
1848
 
1818
1849
  // Search is *finding* new content — refused when the gate decides this
1819
1850
  // request is gated (resume above is unaffected: the gate never blocks
1820
1851
  // hearing, and the gate check stays after the resume check).
1821
1852
  let outcome = await browser.gateDecision(
1822
- for: NativeGateRequest(reason: .search, path: nil, search: params)
1853
+ for: NativeGateRequest(reason: .search, path: nil, search: params),
1823
1854
  )
1824
1855
  guard !outcome.gated else {
1825
1856
  browser.logger.info("handlePlayMediaIntent: search refused — gated")
@@ -48,6 +48,24 @@ enum RemoteCommand: CustomStringConvertible, Equatable {
48
48
 
49
49
  var key: String { description }
50
50
 
51
+ /**
52
+ Commands in `self` that are absent from `next` and so should be disabled.
53
+
54
+ Compared by `key`, not by `==`. `RemoteCommand` is `Equatable` including its
55
+ associated values, but every case with associated values maps onto a single
56
+ `MPRemoteCommand` addressed by `key`: `.skipForward([15])` and
57
+ `.skipForward([30])` are two different values of one command. Diffing by `==`
58
+ would report the old interval as removed and disable the command that the new
59
+ interval had just re-enabled.
60
+ */
61
+ static func commandsToDisable(
62
+ enabled: [RemoteCommand],
63
+ replacedBy next: [RemoteCommand],
64
+ ) -> [RemoteCommand] {
65
+ let nextKeys = Set(next.map(\.key))
66
+ return enabled.filter { !nextKeys.contains($0.key) }
67
+ }
68
+
51
69
  /**
52
70
  All values in an array for convenience.
53
71
  Don't use for associated values.
@@ -103,10 +103,10 @@ public extension TrackPlayerError.PlaybackError {
103
103
  static func kind(forHttpStatus status: Int) -> PlaybackErrorKind {
104
104
  switch status {
105
105
  case 404, 410: .notFound
106
- case 500...599: .serverError
106
+ case 500 ... 599: .serverError
107
107
  // Every other 4xx is the server refusing us — auth, geo-blocking, a
108
108
  // rate limit. All of them mean "you can't have this stream", not "retry".
109
- case 400...499: .rejected
109
+ case 400 ... 499: .rejected
110
110
  default: .unknown
111
111
  }
112
112
  }
@@ -99,7 +99,7 @@ extension NativeIOSSetupOptions {
99
99
  category: AVAudioSession.Category,
100
100
  mode: AVAudioSession.Mode,
101
101
  policy: AVAudioSession.RouteSharingPolicy,
102
- options: AVAudioSession.CategoryOptions
102
+ options: AVAudioSession.CategoryOptions,
103
103
  ) {
104
104
  let category = self.category
105
105
  .flatMap { SessionCategory(rawValue: $0.stringValue) }?
@@ -113,7 +113,8 @@ extension NativeIOSSetupOptions {
113
113
  let options = (self.categoryOptions ?? [])
114
114
  .reduce(into: AVAudioSession.CategoryOptions()) { acc, option in
115
115
  if let mapped = SessionCategoryOptions(rawValue: option.stringValue)?
116
- .mapConfigToAVAudioSessionCategoryOptions() {
116
+ .mapConfigToAVAudioSessionCategoryOptions()
117
+ {
117
118
  acc.insert(mapped)
118
119
  }
119
120
  }
@@ -43,7 +43,7 @@ public struct MediaIntentCriteria: Sendable {
43
43
  artist: String? = nil,
44
44
  album: String? = nil,
45
45
  title: String? = nil,
46
- playlist: String? = nil
46
+ playlist: String? = nil,
47
47
  ) {
48
48
  self.query = query
49
49
  self.reference = reference
@@ -90,7 +90,7 @@ public struct MediaIntentCriteria: Sendable {
90
90
  mediaTypeMode: String?,
91
91
  reference: Reference,
92
92
  hasMediaType: Bool,
93
- appName: String?
93
+ appName: String?,
94
94
  ) -> MediaIntentCriteria {
95
95
  let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
96
96
  let trimmedNonEmpty: (String?) -> String? = {
@@ -115,12 +115,12 @@ public struct MediaIntentCriteria: Sendable {
115
115
  hasGenres: !genreNames.isEmpty,
116
116
  hasMediaType: hasMediaType,
117
117
  matchesAppName: queryMatchesAppName(query, appName: appName),
118
- searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
118
+ searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
119
119
  genre: genre,
120
120
  artist: artist,
121
121
  album: album,
122
122
  title: title,
123
- playlist: playlist
123
+ playlist: playlist,
124
124
  )
125
125
  }
126
126
 
@@ -138,7 +138,7 @@ public enum PlaybackErrorContext {
138
138
  }
139
139
  }
140
140
 
141
- if let httpStatusCode, !(200...299).contains(httpStatusCode) {
141
+ if let httpStatusCode, !(200 ... 299).contains(httpStatusCode) {
142
142
  return .httpStatus(httpStatusCode)
143
143
  }
144
144
 
@@ -155,7 +155,7 @@ public enum PlaybackErrorContext {
155
155
  /// with HTTP statuses — so only in-range values count, and the newest wins:
156
156
  /// a live stream reconnects, and older entries describe earlier attempts.
157
157
  public static func httpStatusCode(fromErrorStatusCodes codes: [Int]) -> Int? {
158
- codes.last { (100...599).contains($0) }
158
+ codes.last { (100 ... 599).contains($0) }
159
159
  }
160
160
 
161
161
  /// The stream was fetched but cannot be decoded — unknown container,
@@ -170,7 +170,8 @@ class RetryManager {
170
170
  return "max retry duration (\(maxRetryDuration)s)"
171
171
  }
172
172
  if !hasPlayed, isOnline, let start = firstOnlineRetryTime,
173
- now.timeIntervalSince(start) >= firstConnectMaxRetryDuration {
173
+ now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
174
+ {
174
175
  return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
175
176
  }
176
177
  return nil
@@ -0,0 +1,47 @@
1
+ #if canImport(NitroModules)
2
+ import NitroModules
3
+ #endif
4
+
5
+ /// The callbacks `RemoteCommandController` invokes when the system delivers a
6
+ /// remote command (lock screen, Control Center, CarPlay, headset).
7
+ ///
8
+ /// Split out of `TrackPlayerCallbacks` — which the controller does not otherwise
9
+ /// use — so the controller depends only on what it calls. `TrackPlayerCallbacks`
10
+ /// extends this protocol, so conformers implement both at once.
11
+ @MainActor protocol RemoteCommandCallbacks: AnyObject {
12
+ /// Called when play is triggered remotely.
13
+ func remotePlay()
14
+
15
+ /// Called when pause is triggered remotely.
16
+ func remotePause()
17
+
18
+ /// Called when stop is triggered remotely.
19
+ func remoteStop()
20
+
21
+ /// Called when toggle play/pause is triggered remotely.
22
+ func remotePlayPause()
23
+
24
+ /// Called when next is triggered remotely.
25
+ func remoteNext()
26
+
27
+ /// Called when previous is triggered remotely.
28
+ func remotePrevious()
29
+
30
+ /// Called when jump forward is triggered remotely.
31
+ func remoteJumpForward(interval: Double)
32
+
33
+ /// Called when jump backward is triggered remotely.
34
+ func remoteJumpBackward(interval: Double)
35
+
36
+ /// Called when seek is triggered remotely.
37
+ func remoteSeek(position: Double)
38
+
39
+ /// Called when repeat mode change is triggered remotely (CarPlay/lock screen).
40
+ func remoteChangeRepeatMode(mode: RepeatMode)
41
+
42
+ /// Called when shuffle mode change is triggered remotely (CarPlay/lock screen).
43
+ func remoteChangeShuffleMode(enabled: Bool)
44
+
45
+ /// Called when playback rate change is triggered remotely (CarPlay/lock screen).
46
+ func remoteChangePlaybackRate(rate: Float)
47
+ }
@@ -46,7 +46,7 @@ class RemoteCommandController {
46
46
  private let logger = Logger(subsystem: "com.audiobrowser", category: "RemoteCommandController")
47
47
  private var center: MPRemoteCommandCenter
48
48
 
49
- weak var callbacks: TrackPlayerCallbacks?
49
+ weak var callbacks: RemoteCommandCallbacks?
50
50
 
51
51
  var commandTargetPointers: [String: Any] = [:]
52
52
  private var enabledCommands: [RemoteCommand] = []
@@ -72,7 +72,7 @@ class RemoteCommandController {
72
72
  */
73
73
  init(
74
74
  remoteCommandCenter: MPRemoteCommandCenter = MPRemoteCommandCenter.shared(),
75
- callbacks: TrackPlayerCallbacks? = nil,
75
+ callbacks: RemoteCommandCallbacks? = nil,
76
76
  ) {
77
77
  center = remoteCommandCenter
78
78
  self.callbacks = callbacks
@@ -112,13 +112,16 @@ class RemoteCommandController {
112
112
  }
113
113
 
114
114
  func enable(commands: [RemoteCommand]) {
115
- let commandsToDisable = enabledCommands.filter { command in
116
- !commands.contains(command)
117
- }
115
+ let commandsToDisable = RemoteCommand.commandsToDisable(
116
+ enabled: enabledCommands, replacedBy: commands,
117
+ )
118
118
 
119
119
  enabledCommands = commands
120
- commands.forEach { self.enable(command: $0) }
120
+ // Disable first. The key diff already makes the two sets disjoint, so this
121
+ // is belt-and-braces: it keeps a disable from tearing down a command the
122
+ // enable pass just configured should the two ever overlap again.
121
123
  disable(commands: commandsToDisable)
124
+ commands.forEach { self.enable(command: $0) }
122
125
  }
123
126
 
124
127
  func disable(commands: [RemoteCommand]) {
@@ -246,7 +246,9 @@ struct ResetTests {
246
246
  c.capture(position: 25.0)
247
247
 
248
248
  // Let the (immediately-cancelled, itemless) seek completion drain.
249
- for _ in 0 ..< 20 { await Task.yield() }
249
+ for _ in 0 ..< 20 {
250
+ await Task.yield()
251
+ }
250
252
 
251
253
  #expect(spy.calls.isEmpty)
252
254
  #expect(c.pendingTime == 25.0)
@@ -57,12 +57,12 @@ struct MediaIntentCriteriaFromTests {
57
57
  mediaTypeMode: String? = nil,
58
58
  reference: MediaIntentCriteria.Reference = .unknown,
59
59
  hasMediaType: Bool = false,
60
- appName: String? = "Radio Garden"
60
+ appName: String? = "Radio Garden",
61
61
  ) -> MediaIntentCriteria {
62
62
  MediaIntentCriteria.from(
63
63
  mediaName: mediaName, genreNames: genreNames, artistName: artistName,
64
64
  albumName: albumName, mediaTypeMode: mediaTypeMode,
65
- reference: reference, hasMediaType: hasMediaType, appName: appName
65
+ reference: reference, hasMediaType: hasMediaType, appName: appName,
66
66
  )
67
67
  }
68
68
 
@@ -120,7 +120,7 @@ struct TransitionTests {
120
120
  @MainActor
121
121
  private func makeRetrying() -> (
122
122
  PlaybackCoordinator, MockRetryHandling, MockPlaybackCoordinatorCallbacks,
123
- MockPlaybackEffectHandler
123
+ MockPlaybackEffectHandler,
124
124
  ) {
125
125
  let retryHandler = MockRetryHandling()
126
126
  retryHandler.isRetryableResult = true