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
@@ -1,3 +1,4 @@
1
+ import AVFoundation
1
2
  import Foundation
2
3
  #if canImport(NitroModules)
3
4
  import NitroModules
@@ -122,30 +123,89 @@ class RetryManager {
122
123
 
123
124
  // MARK: - Error Classification
124
125
 
125
- func isRetryable(_ error: Error?) -> Bool {
126
+ /// HTTP statuses worth another attempt. Same set as Android's
127
+ /// `RetryLoadErrorHandlingPolicy.RETRYABLE_HTTP_STATUS_CODES`, so a flaky
128
+ /// server behaves the same on both platforms.
129
+ private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
130
+
131
+ /// AVFoundation failures a retry cannot fix: the media is unusable rather
132
+ /// than the transport unreliable. Everything not listed falls through to
133
+ /// retry — `AVErrorUnknown` especially, which is the wrapper AVFoundation
134
+ /// puts around opaque CoreMedia transport failures.
135
+ private static let fatalAVErrorCodes: Set<Int> = [
136
+ AVError.fileFormatNotRecognized.rawValue,
137
+ AVError.failedToParse.rawValue,
138
+ AVError.decodeFailed.rawValue,
139
+ AVError.contentIsProtected.rawValue,
140
+ AVError.contentIsNotAuthorized.rawValue,
141
+ ]
142
+
143
+ /// Whether another attempt could plausibly succeed.
144
+ ///
145
+ /// Mirrors Android's `classifyError`, including its default: an unrecognized
146
+ /// failure is treated as *transient* and retried, bounded by the retry
147
+ /// budgets, rather than being terminal. Defaulting the other way made
148
+ /// `retry: true` largely inert on iOS, because AVFoundation reports most
149
+ /// stream failures as opaque `CoreMediaErrorDomain` / `AVFoundationErrorDomain`
150
+ /// errors rather than `URLError`.
151
+ ///
152
+ /// - Parameter httpStatusCode: The status from the item's error log, when the
153
+ /// caller has one. `AVPlayerItem.error` never carries it — a 404 arrives as
154
+ /// an opaque CoreMedia error — so this is the only reliable HTTP signal and
155
+ /// it outranks the error itself.
156
+ func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
157
+ if let status = httpStatusCode {
158
+ let retryable = Self.retryableHTTPStatusCodes.contains(status) || (500 ... 599).contains(status)
159
+ logger.debug("HTTP \(status) \(retryable ? "is" : "not") retryable")
160
+ return retryable
161
+ }
162
+
126
163
  guard let error else { return false }
127
164
 
128
- if let urlError = error as? URLError {
129
- switch urlError.code {
130
- // Transient network errors - safe to retry
131
- case .timedOut,
132
- .networkConnectionLost,
133
- .notConnectedToInternet,
134
- .cannotConnectToHost,
135
- .cannotFindHost,
136
- .dnsLookupFailed:
137
- logger.debug("URLError is retryable: code=\(urlError.code.rawValue)")
138
- return true
139
- default:
140
- logger.debug("URLError not retryable: code=\(urlError.code.rawValue)")
165
+ // Walk the underlying-error chain: AVFoundation routinely wraps the real
166
+ // cause (a URLError, or a CoreMedia code) in an AVError.
167
+ for candidate in Self.errorChain(error) {
168
+ if let urlError = candidate as? URLError {
169
+ switch urlError.code {
170
+ // Transient network errors - safe to retry
171
+ case .timedOut,
172
+ .networkConnectionLost,
173
+ .notConnectedToInternet,
174
+ .cannotConnectToHost,
175
+ .cannotFindHost,
176
+ .dnsLookupFailed:
177
+ logger.debug("URLError is retryable: code=\(urlError.code.rawValue)")
178
+ return true
179
+ default:
180
+ logger.debug("URLError not retryable: code=\(urlError.code.rawValue)")
181
+ return false
182
+ }
183
+ }
184
+
185
+ let nsError = candidate as NSError
186
+ if nsError.domain == AVFoundationErrorDomain,
187
+ Self.fatalAVErrorCodes.contains(nsError.code)
188
+ {
189
+ logger.debug("AVError not retryable: code=\(nsError.code)")
141
190
  return false
142
191
  }
143
192
  }
144
193
 
145
- // Unknown errors - don't retry
146
194
  let nsError = error as NSError
147
- logger.debug("Error not retryable: domain=\(nsError.domain), code=\(nsError.code)")
148
- return false
195
+ logger.debug("Treating as transient: domain=\(nsError.domain), code=\(nsError.code)")
196
+ return true
197
+ }
198
+
199
+ /// The error and its `NSUnderlyingError` ancestors, outermost first.
200
+ private static func errorChain(_ error: Error) -> [Error] {
201
+ var chain: [Error] = []
202
+ var current: Error? = error
203
+ // Bounded: a malformed cycle must not spin here.
204
+ while let error = current, chain.count < 8 {
205
+ chain.append(error)
206
+ current = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error
207
+ }
208
+ return chain
149
209
  }
150
210
 
151
211
  // MARK: - Retry Management
@@ -170,7 +230,8 @@ class RetryManager {
170
230
  return "max retry duration (\(maxRetryDuration)s)"
171
231
  }
172
232
  if !hasPlayed, isOnline, let start = firstOnlineRetryTime,
173
- now.timeIntervalSince(start) >= firstConnectMaxRetryDuration {
233
+ now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
234
+ {
174
235
  return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
175
236
  }
176
237
  return nil
@@ -39,6 +39,16 @@ struct ShuffleOrder {
39
39
  indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
40
40
  }
41
41
 
42
+ /// Creates a shuffle order for the given length with `firstIndex` pinned to shuffle position 0.
43
+ ///
44
+ /// Playback starts on that track, so leaving it wherever the permutation put it would end the
45
+ /// queue as soon as it is reached — on average after half the queue, and immediately whenever it
46
+ /// lands last.
47
+ init(length: Int, firstIndex: Int) {
48
+ self.init(length: length)
49
+ reshuffle(keepingFirst: firstIndex)
50
+ }
51
+
42
52
  /// Creates a shuffle order with pre-defined indices
43
53
  init(shuffledIndices: [Int]) {
44
54
  shuffled = shuffledIndices
@@ -7,15 +7,51 @@ import Foundation
7
7
  /// Type alias for the Nitro SleepTimer variant type
8
8
  typealias SleepTimerState = SleepTimer
9
9
 
10
+ /// A scheduled job that has not run yet. `cancel()` is nonisolated so `deinit`
11
+ /// — always nonisolated in Swift 6 — can tear pending work down.
12
+ protocol SleepTimerJob: AnyObject {
13
+ func cancel()
14
+ }
15
+
16
+ extension DispatchWorkItem: SleepTimerJob {}
17
+
18
+ /// Runs work after a delay. Injected into `SleepTimerManager` so tests can
19
+ /// drive the clock instead of racing it: production schedules on the main
20
+ /// queue, tests hand in a scheduler they advance by hand.
21
+ @MainActor
22
+ protocol SleepTimerScheduling {
23
+ func schedule(
24
+ after delay: TimeInterval,
25
+ _ work: @escaping @Sendable @MainActor () -> Void,
26
+ ) -> any SleepTimerJob
27
+ }
28
+
29
+ struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
30
+ func schedule(
31
+ after delay: TimeInterval,
32
+ _ work: @escaping @Sendable @MainActor () -> Void,
33
+ ) -> any SleepTimerJob {
34
+ let job = DispatchWorkItem { MainActor.assumeIsolated { work() } }
35
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: job)
36
+ return job
37
+ }
38
+ }
39
+
10
40
  /// Manages sleep timer functionality for the audio player.
11
41
  /// Supports both time-based timers and end-of-track timers.
12
42
  @MainActor
13
43
  class SleepTimerManager: SleepTimerHandling {
14
44
  // MARK: - Properties
15
45
 
46
+ private let scheduler: any SleepTimerScheduling
47
+
16
48
  // nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
17
- private nonisolated(unsafe) var sleepTimerJob: DispatchWorkItem?
18
- private nonisolated(unsafe) var fadeJob: DispatchWorkItem?
49
+ private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
50
+ private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
51
+
52
+ init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
53
+ self.scheduler = scheduler
54
+ }
19
55
 
20
56
  /// The time when playback should stop (seconds since epoch), or -1 if inactive
21
57
  private(set) var sleepTimerTime: TimeInterval = -1 {
@@ -94,23 +130,15 @@ class SleepTimerManager: SleepTimerHandling {
94
130
  func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
95
131
  cancelSleepTimerJob()
96
132
  onFadeCancel?()
97
- let job = DispatchWorkItem { [weak self] in
98
- MainActor.assumeIsolated {
99
- self?.complete()
100
- }
101
- }
102
- sleepTimerJob = job
103
133
  sleepTimerTime = Date().timeIntervalSince1970 + seconds
104
- DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: job)
134
+ sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
135
+ self?.complete()
136
+ }
105
137
  if let fadeDuration, fadeDuration > 0 {
106
138
  let fade = min(fadeDuration, seconds)
107
- let fadeJob = DispatchWorkItem { [weak self] in
108
- MainActor.assumeIsolated {
109
- self?.onFadeStart?(fade)
110
- }
139
+ fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
140
+ self?.onFadeStart?(fade)
111
141
  }
112
- self.fadeJob = fadeJob
113
- DispatchQueue.main.asyncAfter(deadline: .now() + (seconds - fade), execute: fadeJob)
114
142
  }
115
143
  if let state = get() { onChanged?(state) }
116
144
  }
@@ -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]) {
@@ -0,0 +1,111 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @Suite("LRUCache")
7
+ struct LRUCacheTests {
8
+ /// A class so the tests can observe release through a weak reference.
9
+ private final class Box {
10
+ let id: Int
11
+ init(_ id: Int) { self.id = id }
12
+ }
13
+
14
+ @Test func evictsTheLeastRecentlyUsed() {
15
+ let cache = LRUCache<String, Int>(maxSize: 2)
16
+ cache.set("a", value: 1)
17
+ cache.set("b", value: 2)
18
+ cache.set("c", value: 3)
19
+
20
+ #expect(cache.get("a") == nil)
21
+ #expect(cache.get("b") == 2)
22
+ #expect(cache.get("c") == 3)
23
+ #expect(cache.count == 2)
24
+ }
25
+
26
+ /// A read is a use: it must move the entry off the eviction end.
27
+ @Test func readingPromotesAnEntry() {
28
+ let cache = LRUCache<String, Int>(maxSize: 2)
29
+ cache.set("a", value: 1)
30
+ cache.set("b", value: 2)
31
+ _ = cache.get("a")
32
+ cache.set("c", value: 3)
33
+
34
+ #expect(cache.get("a") == 1)
35
+ #expect(cache.get("b") == nil)
36
+ }
37
+
38
+ @Test func removeDropsOnlyItsOwnEntry() {
39
+ let cache = LRUCache<String, Int>(maxSize: 3)
40
+ cache.set("a", value: 1)
41
+ cache.set("b", value: 2)
42
+ cache.set("c", value: 3)
43
+
44
+ cache.remove("b")
45
+
46
+ #expect(cache.get("b") == nil)
47
+ #expect(cache.get("a") == 1)
48
+ #expect(cache.get("c") == 3)
49
+ #expect(cache.count == 2)
50
+ }
51
+
52
+ /// The nodes form a doubly-linked list. If both directions were strong, adjacent nodes would
53
+ /// retain each other and clearing would drop only the dictionary and the two ends — the chain,
54
+ /// and everything it holds, would survive (#96).
55
+ @Test func clearReleasesEveryNode() {
56
+ let cache = LRUCache<String, Box>(maxSize: 10)
57
+ weak var first: Box?
58
+ weak var middle: Box?
59
+ weak var last: Box?
60
+
61
+ do {
62
+ let a = Box(1), b = Box(2), c = Box(3)
63
+ (first, middle, last) = (a, b, c)
64
+ cache.set("a", value: a)
65
+ cache.set("b", value: b)
66
+ cache.set("c", value: c)
67
+ }
68
+
69
+ #expect(first != nil, "the cache should still hold its entries")
70
+
71
+ cache.clear()
72
+
73
+ #expect(first == nil)
74
+ #expect(middle == nil)
75
+ #expect(last == nil)
76
+ #expect(cache.count == 0)
77
+ }
78
+
79
+ /// Same cycle, reached by dropping the cache itself rather than clearing it.
80
+ @Test func droppingTheCacheReleasesEveryNode() {
81
+ weak var first: Box?
82
+ weak var last: Box?
83
+
84
+ do {
85
+ let cache = LRUCache<String, Box>(maxSize: 10)
86
+ let a = Box(1), b = Box(2)
87
+ (first, last) = (a, b)
88
+ cache.set("a", value: a)
89
+ cache.set("b", value: b)
90
+ #expect(first != nil)
91
+ }
92
+
93
+ #expect(first == nil)
94
+ #expect(last == nil)
95
+ }
96
+
97
+ /// Eviction has always unlinked the node it drops; this pins that down alongside the rest.
98
+ @Test func evictionReleasesTheEvictedNode() {
99
+ let cache = LRUCache<String, Box>(maxSize: 1)
100
+ weak var evicted: Box?
101
+
102
+ do {
103
+ let a = Box(1)
104
+ evicted = a
105
+ cache.set("a", value: a)
106
+ cache.set("b", value: Box(2))
107
+ }
108
+
109
+ #expect(evicted == nil)
110
+ }
111
+ }
@@ -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
 
@@ -123,8 +123,7 @@ struct ResolveAndLoadTests {
123
123
  }
124
124
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
125
125
 
126
- // Allow the resolver task to run
127
- await Task.yield()
126
+ await loader.mediaResolverTask?.value
128
127
 
129
128
  #expect(resolverCalled == true)
130
129
  }
@@ -136,12 +135,9 @@ struct ResolveAndLoadTests {
136
135
  }
137
136
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
138
137
 
139
- // The resolver runs in a Task that awaits the resolver then does MainActor.run,
140
- // so we need multiple yields for the full chain to settle.
141
- for _ in 0 ..< 10 {
142
- await Task.yield()
143
- if !spy.playbackErrors.isEmpty { break }
144
- }
138
+ // The task awaits the resolver then hops back to the main actor, so the
139
+ // delegate call has landed by the time the task itself completes.
140
+ await loader.mediaResolverTask?.value
145
141
 
146
142
  #expect(spy.playbackErrors.count == 1)
147
143
  if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
@@ -166,8 +162,8 @@ struct ResolveAndLoadTests {
166
162
  // Immediately start second — should cancel first
167
163
  loader.resolveAndLoad(src: "https://example.com/second.mp3")
168
164
 
169
- // Wait for both to settle
170
- try? await Task.sleep(for: .milliseconds(200))
165
+ // Await the surviving task; the cancelled one bailed before its resolver.
166
+ await loader.mediaResolverTask?.value
171
167
 
172
168
  // Only the second resolver should have run (first was cancelled before calling resolver)
173
169
  #expect(callCount == 1)
@@ -188,10 +184,11 @@ struct CancelAllTests {
188
184
  return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
189
185
  }
190
186
  loader.resolveAndLoad(src: "https://example.com/audio.mp3")
187
+ // cancelAll() drops the handle, so grab it first to await the point where
188
+ // the callback would have fired.
189
+ let resolve = loader.mediaResolverTask
191
190
  loader.cancelAll()
192
-
193
- // Wait for what would have been the callback
194
- try? await Task.sleep(for: .milliseconds(100))
191
+ await resolve?.value
195
192
 
196
193
  #expect(spy.preparedItems.isEmpty)
197
194
  #expect(spy.playbackErrors.isEmpty)
@@ -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
@@ -743,7 +743,7 @@ struct NaturalEndPlayIntentTests {
743
743
  @Suite("PlaybackCoordinator - healthy playback resets retry budget")
744
744
  struct HealthyPlaybackRetryResetTests {
745
745
  @Test @MainActor
746
- func sustainedPlayback_resetsRetryBudget() async throws {
746
+ func sustainedPlayback_resetsRetryBudget() async {
747
747
  let retryHandler = MockRetryHandling()
748
748
  let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
749
749
  let coordinator = PlaybackCoordinator(
@@ -754,7 +754,7 @@ struct HealthyPlaybackRetryResetTests {
754
754
  coordinator.healthyPlaybackDuration = 0.01
755
755
 
756
756
  coordinator.transition(.avPlayerPlaying)
757
- try await Task.sleep(nanoseconds: 100_000_000)
757
+ await coordinator.healthyPlaybackTask?.value
758
758
 
759
759
  #expect(retryHandler.resetCallCount == 1)
760
760
  }
@@ -762,7 +762,7 @@ struct HealthyPlaybackRetryResetTests {
762
762
  /// resetRetry() cancels a pending retry — firing the refill mid-episode
763
763
  /// would kill an in-flight reconnect silently (stuck .playing forever).
764
764
  @Test @MainActor
765
- func refill_skipsWhileRetryIsPending() async throws {
765
+ func refill_skipsWhileRetryIsPending() async {
766
766
  let retryHandler = MockRetryHandling()
767
767
  retryHandler.isRetryableResult = true
768
768
  retryHandler.attemptRetryDelayNs = 1_000_000_000
@@ -776,14 +776,14 @@ struct HealthyPlaybackRetryResetTests {
776
776
 
777
777
  coordinator.transition(.avPlayerPlaying)
778
778
  errorHandler.handleError(URLError(.timedOut), context: .playback)
779
- try await Task.sleep(nanoseconds: 100_000_000)
779
+ await coordinator.healthyPlaybackTask?.value
780
780
 
781
781
  #expect(retryHandler.resetCallCount == 0)
782
782
  #expect(errorHandler.pendingRetryTask != nil)
783
783
  }
784
784
 
785
785
  @Test @MainActor
786
- func playbackInterruptedBeforeThreshold_doesNotReset() async throws {
786
+ func playbackInterruptedBeforeThreshold_doesNotReset() async {
787
787
  let retryHandler = MockRetryHandling()
788
788
  let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
789
789
  let coordinator = PlaybackCoordinator(
@@ -795,9 +795,12 @@ struct HealthyPlaybackRetryResetTests {
795
795
  coordinator.healthyPlaybackDuration = 0.5
796
796
 
797
797
  coordinator.transition(.avPlayerPlaying)
798
+ let refill = coordinator.healthyPlaybackTask
798
799
  coordinator.transition(.avPlayerWaiting) // stalls before the threshold
799
800
 
800
- try await Task.sleep(nanoseconds: 100_000_000)
801
+ // Leaving .playing cancels the refill; awaiting the cancelled task is the
802
+ // deterministic way to reach the point where it would have fired.
803
+ await refill?.value
801
804
 
802
805
  #expect(retryHandler.resetCallCount == 0)
803
806
  }
@@ -14,9 +14,11 @@ private final class MockRetryHandler: RetryHandling {
14
14
  var attemptRetryResult = false
15
15
  var attemptRetryCallCount = 0
16
16
  var lastStartFromCurrentTime: Bool?
17
+ var lastHTTPStatusCode: Int??
17
18
  var resetCallCount = 0
18
19
 
19
- func isRetryable(_ error: Error?) -> Bool {
20
+ func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
21
+ lastHTTPStatusCode = .some(httpStatusCode)
20
22
  guard let error else { return false }
21
23
  let code = (error as NSError).code
22
24
  return retryableErrors.contains(code)
@@ -0,0 +1,37 @@
1
+ import AVFoundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @MainActor
7
+ @Suite("PlayerTimeObserver")
8
+ struct PlayerTimeObserverTests {
9
+ /// An AVPlayer retains its time observers, so one left registered outlives both this object and
10
+ /// the caller's interest in it (#96).
11
+ @Test func unregisteringDropsTheToken() {
12
+ // Held: `avPlayer` is weak, so a temporary is gone before the next line.
13
+ let player = AVPlayer()
14
+ let observer = PlayerTimeObserver(onAudioDidStart: {})
15
+ observer.avPlayer = player
16
+
17
+ observer.registerForBoundaryTimeEvents()
18
+ #expect(observer.boundaryTimeStartObserverToken != nil)
19
+
20
+ observer.unregisterForBoundaryTimeEvents()
21
+ #expect(observer.boundaryTimeStartObserverToken == nil)
22
+ }
23
+
24
+ /// Swapping players has to unregister from the outgoing one — it is a different AVPlayer, and
25
+ /// the token cannot be removed from it afterwards.
26
+ @Test func swappingPlayersUnregistersFromThePrevious() {
27
+ let first = AVPlayer()
28
+ let second = AVPlayer()
29
+ let observer = PlayerTimeObserver(onAudioDidStart: {})
30
+ observer.avPlayer = first
31
+ observer.registerForBoundaryTimeEvents()
32
+
33
+ observer.avPlayer = second
34
+
35
+ #expect(observer.boundaryTimeStartObserverToken == nil)
36
+ }
37
+ }