react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.11

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 (290) hide show
  1. package/README.md +22 -18
  2. package/android/build.gradle +13 -10
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +76 -21
  6. package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
  7. package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
  8. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
  9. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  10. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  11. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  12. package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
  14. package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
  15. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
  16. package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
  17. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  18. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  19. package/android/src/main/java/com/audiobrowser/player/Player.kt +30 -8
  20. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  21. package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +2 -1
  22. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +67 -11
  23. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
  24. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  25. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
  26. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
  27. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  28. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  29. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  30. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +52 -1
  31. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
  32. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +167 -8
  33. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  34. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  36. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  38. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  39. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
  40. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
  41. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  42. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  43. package/ios/Browser/BrowserManager.swift +2 -27
  44. package/ios/Browser/SearchDrillIn.swift +1 -1
  45. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  46. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  47. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  48. package/ios/HybridAudioBrowser.swift +87 -56
  49. package/ios/Model/NitroTypeStubs.swift +18 -2
  50. package/ios/Model/RemoteCommand.swift +18 -0
  51. package/ios/Model/TrackPlayerError.swift +2 -2
  52. package/ios/Option/SessionCategories.swift +3 -2
  53. package/ios/Player/MediaIntentCriteria.swift +4 -4
  54. package/ios/Player/MediaLoader.swift +3 -1
  55. package/ios/Player/PlaybackCoordinator.swift +4 -1
  56. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  57. package/ios/Player/QueueManager.swift +10 -3
  58. package/ios/Player/RetryManager.swift +79 -18
  59. package/ios/Player/ShuffleOrder.swift +10 -0
  60. package/ios/Player/SleepTimerManager.swift +43 -15
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  62. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  63. package/ios/Tests/LRUCacheTests.swift +111 -0
  64. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  65. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  66. package/ios/Tests/MediaLoaderTests.swift +10 -13
  67. package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
  68. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  69. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  70. package/ios/Tests/QueueManagerTests.swift +50 -4
  71. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  72. package/ios/Tests/RemoteCommandTests.swift +67 -0
  73. package/ios/Tests/RetryManagerTests.swift +89 -0
  74. package/ios/Tests/SectionScopeTests.swift +2 -2
  75. package/ios/Tests/SleepTimerManagerTests.swift +22 -19
  76. package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
  77. package/ios/Tests/Support/TestTypes.swift +1 -1
  78. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  79. package/ios/TrackPlayer.swift +6 -1
  80. package/ios/TrackPlayerCallbacks.swift +5 -38
  81. package/ios/Util/LRUCache.swift +4 -1
  82. package/lib/commonjs/AudioBrowser.js +0 -11
  83. package/lib/commonjs/AudioBrowser.js.map +1 -1
  84. package/lib/commonjs/CLAUDE.md +52 -0
  85. package/lib/commonjs/features/browser.js +1 -1
  86. package/lib/commonjs/features/browser.js.map +1 -1
  87. package/lib/commonjs/features/carConnection.js +1 -1
  88. package/lib/commonjs/features/equalizer.js +1 -1
  89. package/lib/commonjs/features/errors.js +3 -3
  90. package/lib/commonjs/features/favorites.js +1 -1
  91. package/lib/commonjs/features/gate.js +16 -1
  92. package/lib/commonjs/features/gate.js.map +1 -1
  93. package/lib/commonjs/features/metadata.js +20 -3
  94. package/lib/commonjs/features/metadata.js.map +1 -1
  95. package/lib/commonjs/features/network.js +1 -1
  96. package/lib/commonjs/features/nowPlaying.js +1 -1
  97. package/lib/commonjs/features/output.js +1 -1
  98. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  99. package/lib/commonjs/features/playback/playing.js +1 -1
  100. package/lib/commonjs/features/playback/progress.js +1 -1
  101. package/lib/commonjs/features/playback/state.js +1 -1
  102. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  103. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  104. package/lib/commonjs/features/playback/volume.js +1 -1
  105. package/lib/commonjs/features/player/options.js +48 -34
  106. package/lib/commonjs/features/player/options.js.map +1 -1
  107. package/lib/commonjs/features/player/setup.js +15 -8
  108. package/lib/commonjs/features/player/setup.js.map +1 -1
  109. package/lib/commonjs/features/player/validateOptions.js +21 -0
  110. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  111. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  112. package/lib/commonjs/features/queue/queue.js +2 -2
  113. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  114. package/lib/commonjs/features/queue/shuffle.js +1 -1
  115. package/lib/commonjs/features/remoteControls.js +13 -92
  116. package/lib/commonjs/features/remoteControls.js.map +1 -1
  117. package/lib/commonjs/features/sleepTimer.js +13 -1
  118. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  119. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  120. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  121. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  122. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  123. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  124. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  125. package/lib/commonjs/web/browser/BrowserManager.js +1 -1
  126. package/lib/module/AudioBrowser.js +1 -1
  127. package/lib/module/AudioBrowser.js.map +1 -1
  128. package/lib/module/CLAUDE.md +52 -0
  129. package/lib/module/features/browser.js +1 -1
  130. package/lib/module/features/browser.js.map +1 -1
  131. package/lib/module/features/carConnection.js +1 -1
  132. package/lib/module/features/equalizer.js +1 -1
  133. package/lib/module/features/errors.js +3 -3
  134. package/lib/module/features/favorites.js +1 -1
  135. package/lib/module/features/gate.js +16 -1
  136. package/lib/module/features/gate.js.map +1 -1
  137. package/lib/module/features/metadata.js +20 -3
  138. package/lib/module/features/metadata.js.map +1 -1
  139. package/lib/module/features/network.js +1 -1
  140. package/lib/module/features/nowPlaying.js +1 -1
  141. package/lib/module/features/output.js +1 -1
  142. package/lib/module/features/playback/playWhenReady.js +1 -1
  143. package/lib/module/features/playback/playing.js +1 -1
  144. package/lib/module/features/playback/progress.js +1 -1
  145. package/lib/module/features/playback/state.js +1 -1
  146. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  147. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  148. package/lib/module/features/playback/volume.js +1 -1
  149. package/lib/module/features/player/options.js +47 -32
  150. package/lib/module/features/player/options.js.map +1 -1
  151. package/lib/module/features/player/setup.js +14 -7
  152. package/lib/module/features/player/setup.js.map +1 -1
  153. package/lib/module/features/player/validateOptions.js +17 -0
  154. package/lib/module/features/player/validateOptions.js.map +1 -0
  155. package/lib/module/features/queue/activeTrack.js +1 -1
  156. package/lib/module/features/queue/queue.js +2 -2
  157. package/lib/module/features/queue/repeatMode.js +1 -1
  158. package/lib/module/features/queue/shuffle.js +1 -1
  159. package/lib/module/features/remoteControls.js +13 -92
  160. package/lib/module/features/remoteControls.js.map +1 -1
  161. package/lib/module/features/sleepTimer.js +13 -1
  162. package/lib/module/features/sleepTimer.js.map +1 -1
  163. package/lib/module/test-utils/reactNativeStub.js +58 -0
  164. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  165. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  166. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  167. package/lib/module/web/NativeAudioBrowser.js +1 -1
  168. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  169. package/lib/module/web/browser/BrowserManager.js +1 -1
  170. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  171. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  172. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  173. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  174. package/lib/typescript/src/features/errors.d.ts +3 -3
  175. package/lib/typescript/src/features/favorites.d.ts +1 -1
  176. package/lib/typescript/src/features/gate.d.ts +18 -2
  177. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  178. package/lib/typescript/src/features/metadata.d.ts +20 -3
  179. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  180. package/lib/typescript/src/features/network.d.ts +1 -1
  181. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  182. package/lib/typescript/src/features/output.d.ts +1 -1
  183. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  184. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  185. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  186. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  187. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  188. package/lib/typescript/src/features/player/options.d.ts +87 -37
  189. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  190. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  191. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  192. package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
  193. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  194. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  195. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  196. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  197. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  198. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  199. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  200. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  201. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  202. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  203. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  204. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  205. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  206. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  207. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  208. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  209. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
  210. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  211. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  212. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  213. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  214. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  215. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  216. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  217. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  218. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  219. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  220. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  221. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  222. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  223. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  224. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  225. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  226. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  227. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
  228. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  229. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  230. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  231. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  232. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  233. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  234. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  235. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  236. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  237. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  238. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  239. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  240. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  241. package/package.json +12 -4
  242. package/src/AudioBrowser.ts +1 -1
  243. package/src/CLAUDE.md +52 -0
  244. package/src/features/battery.test.ts +79 -0
  245. package/src/features/browser.ts +1 -1
  246. package/src/features/carConnection.ts +1 -1
  247. package/src/features/equalizer.ts +1 -1
  248. package/src/features/errors.ts +3 -3
  249. package/src/features/favorites.ts +1 -1
  250. package/src/features/gate.ts +16 -1
  251. package/src/features/hook-wiring.test.ts +254 -0
  252. package/src/features/metadata.ts +20 -3
  253. package/src/features/network.ts +1 -1
  254. package/src/features/nowPlaying.ts +1 -1
  255. package/src/features/output.ts +1 -1
  256. package/src/features/playback/playWhenReady.ts +1 -1
  257. package/src/features/playback/playing.ts +1 -1
  258. package/src/features/playback/progress.test.ts +169 -0
  259. package/src/features/playback/progress.ts +1 -1
  260. package/src/features/playback/state.ts +1 -1
  261. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  262. package/src/features/playback/trackPlaybackTime.ts +0 -5
  263. package/src/features/playback/volume.ts +1 -1
  264. package/src/features/player/options.ts +99 -57
  265. package/src/features/player/setup.test.ts +12 -4
  266. package/src/features/player/setup.ts +19 -9
  267. package/src/features/player/validateOptions.ts +19 -0
  268. package/src/features/queue/activeTrack.ts +1 -1
  269. package/src/features/queue/queue.ts +2 -2
  270. package/src/features/queue/repeatMode.ts +1 -1
  271. package/src/features/queue/shuffle.ts +1 -1
  272. package/src/features/remoteControls.ts +13 -92
  273. package/src/features/sleepTimer.test.ts +172 -0
  274. package/src/features/sleepTimer.ts +9 -1
  275. package/src/nitro-stub-drift.test.ts +237 -0
  276. package/src/public-surface.test.ts +107 -0
  277. package/src/specs/audio-browser.nitro.ts +1 -1
  278. package/src/test-utils/reactNativeStub.ts +60 -0
  279. package/src/utils/LazyNativeEmitter.ts +9 -6
  280. package/src/utils/useDebug.test.ts +182 -0
  281. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  282. package/src/web/NativeAudioBrowser.ts +1 -1
  283. package/src/web/browser/BrowserManager.ts +1 -1
  284. package/ios/Model/MediaURL.swift +0 -67
  285. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  286. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  287. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  289. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  290. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -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)
@@ -457,7 +483,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
457
483
  }
458
484
  }
459
485
 
460
- public func onSearch(query: String) throws -> Promise<[Track]> {
486
+ public func search(query: String) throws -> Promise<[Track]> {
461
487
  Promise.async { [weak self] in
462
488
  guard let self else { return [] }
463
489
  let resolved = try await browserManager.search(query)
@@ -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")
@@ -5,6 +5,12 @@
5
5
  /// These stubs declare `Equatable` for test ergonomics — the generated
6
6
  /// Nitro types do NOT. Shared `ios/` sources must not rely on conformances
7
7
  /// that exist only here: `swift test` compiles, the app build breaks.
8
+ ///
9
+ /// Property names and types ARE checked against `nitrogen/generated/ios/swift`
10
+ /// by `src/nitro-stub-drift.test.ts`, which runs in the JS CI job — neither
11
+ /// native job can see both files at once. Declaring fewer properties than the
12
+ /// real type is fine and deliberate; spelling one differently is what that
13
+ /// test catches. Conformances and default values are still on you.
8
14
 
9
15
  struct ArtworkVariants: Equatable {
10
16
  var light: String
@@ -23,7 +29,7 @@
23
29
  }
24
30
 
25
31
  struct Track: Equatable {
26
- var id: String
32
+ var id: String?
27
33
  var url: String?
28
34
  var src: String?
29
35
  var request: TrackRequest?
@@ -63,9 +69,19 @@
63
69
  }
64
70
  }
65
71
 
72
+ enum HttpMethod {
73
+ case get
74
+ case post
75
+ case put
76
+ case patch
77
+ case delete
78
+ case head
79
+ case options
80
+ }
81
+
66
82
  struct ImageSource: Equatable {
67
83
  var uri: String
68
- var method: String?
84
+ var method: HttpMethod?
69
85
  var headers: [String: String]?
70
86
  var body: String?
71
87
  }
@@ -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
 
@@ -25,7 +25,9 @@ final class MediaLoader {
25
25
 
26
26
  weak var delegate: MediaLoaderDelegate?
27
27
 
28
- private var mediaResolverTask: Task<Void, Never>?
28
+ /// The in-flight resolve. Readable so tests can await the resolve-then-load
29
+ /// chain instead of sleeping past it; only this type starts and cancels it.
30
+ private(set) var mediaResolverTask: Task<Void, Never>?
29
31
  private var metadataLoadTask: Task<Void, Never>?
30
32
  private var playableLoadTask: Task<Void, Never>?
31
33
  private var url: URL?
@@ -76,7 +76,10 @@ class PlaybackCoordinator {
76
76
  /// the window/attempt counters otherwise reset only on track change.
77
77
  /// Overridable in tests.
78
78
  var healthyPlaybackDuration: TimeInterval = 20
79
- private var healthyPlaybackTask: Task<Void, Never>?
79
+
80
+ /// The pending refill. Readable so tests can await the window instead of
81
+ /// sleeping past it; only this type arms and cancels it.
82
+ private(set) var healthyPlaybackTask: Task<Void, Never>?
80
83
 
81
84
  /// Playback rate (1.0 = normal speed).
82
85
  var rate: Float = 1.0
@@ -8,7 +8,7 @@ import os.log
8
8
  var isEnabled: Bool { get }
9
9
  /// Whether the current load has produced audio — selects the retry budget.
10
10
  var hasPlayed: Bool { get set }
11
- func isRetryable(_ error: Error?) -> Bool
11
+ func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool
12
12
  func attemptRetry(startFromCurrentTime: Bool) async -> Bool
13
13
  func reset()
14
14
  }
@@ -62,7 +62,7 @@ public enum PlaybackErrorContext {
62
62
  logger.error("[\(String(describing: context))] failure with nil error")
63
63
  }
64
64
 
65
- if retryHandler.isEnabled, retryHandler.isRetryable(error) {
65
+ if retryHandler.isEnabled, retryHandler.isRetryable(error, httpStatusCode: httpStatusCode) {
66
66
  onRetryingError?(PlaybackErrorHandler.classify(
67
67
  error: error, fallback: context.fallbackError, httpStatusCode: httpStatusCode,
68
68
  ))
@@ -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,
@@ -45,8 +45,15 @@ class QueueManager {
45
45
  /// The repeat mode for the queue. Plain property — no side effects.
46
46
  var repeatMode: RepeatMode = .off
47
47
 
48
- /// Whether shuffle mode is enabled. Plain property — no side effects.
49
- var shuffleEnabled: Bool = false
48
+ /// Whether shuffle mode is enabled.
49
+ var shuffleEnabled: Bool = false {
50
+ didSet {
51
+ // Re-pin on enable: the existing order has the current track wherever it happened to land,
52
+ // so without this, turning shuffle on near the end of the queue ends it right away.
53
+ guard shuffleEnabled, shuffleEnabled != oldValue else { return }
54
+ shuffleOrder.reshuffle(keepingFirst: currentIndex)
55
+ }
56
+ }
50
57
 
51
58
  // MARK: - Computed Properties
52
59
 
@@ -206,7 +213,7 @@ class QueueManager {
206
213
  currentIndex = clampedIndex
207
214
  queueSourcePath = sourcePath
208
215
  tracks = newTracks
209
- shuffleOrder = ShuffleOrder(length: newTracks.count)
216
+ shuffleOrder = ShuffleOrder(length: newTracks.count, firstIndex: clampedIndex)
210
217
  return true
211
218
  }
212
219