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
@@ -74,6 +74,52 @@ struct NextTests {
74
74
  #expect(q.currentIndex == 0)
75
75
  }
76
76
 
77
+ /// The start track must be first in the shuffle order, not wherever the permutation put it —
78
+ /// otherwise auto-advance ends the queue as soon as it is reached (#96).
79
+ @Test func shuffle_startTrackLeadsTheOrder() {
80
+ for start in 0 ..< 6 {
81
+ let q = QueueManager()
82
+ q.setQueue(tracks("a", "b", "c", "d", "e", "f"), initialIndex: start)
83
+ q.shuffleEnabled = true
84
+
85
+ #expect(q.shuffleOrder.isFirst(start))
86
+ #expect(!q.isLastInPlaybackOrder)
87
+ #expect(q.nextTracks.count == 5)
88
+ #expect(q.previousTracks.isEmpty)
89
+ }
90
+ }
91
+
92
+ /// Same hazard on enabling shuffle mid-queue: the existing order has the current track wherever
93
+ /// it landed, so without re-pinning, turning shuffle on can immediately report end-of-queue.
94
+ @Test func shuffle_enablingMidQueueLeadsFromCurrent() {
95
+ for start in 0 ..< 6 {
96
+ let q = QueueManager()
97
+ q.setQueue(tracks("a", "b", "c", "d", "e", "f"))
98
+ _ = try? q.skipTo(start)
99
+
100
+ q.shuffleEnabled = true
101
+
102
+ #expect(q.shuffleOrder.isFirst(start))
103
+ #expect(!q.isLastInPlaybackOrder)
104
+ #expect(q.nextTracks.count == 5)
105
+ }
106
+ }
107
+
108
+ /// Re-pinning happens on enable only — flipping it off and on again must not renege on the
109
+ /// order mid-listen, and a redundant `= true` must not reshuffle under the listener.
110
+ @Test func shuffle_redundantEnableKeepsTheOrder() {
111
+ let q = QueueManager()
112
+ q.setQueue(tracks("a", "b", "c", "d", "e", "f"))
113
+ q.shuffleEnabled = true
114
+ let order = q.shuffleOrder.shuffled
115
+
116
+ q.shuffleEnabled = true
117
+ #expect(q.shuffleOrder.shuffled == order)
118
+
119
+ q.shuffleEnabled = false
120
+ #expect(q.shuffleOrder.shuffled == order)
121
+ }
122
+
77
123
  @Test func shuffle_visitsAllTracks() {
78
124
  let q = QueueManager()
79
125
  q.setQueue(tracks("a", "b", "c", "d"))
@@ -767,13 +813,13 @@ struct ComputedPropertyTests {
767
813
  let q = QueueManager()
768
814
  q.setQueue(tracks("a", "b", "c", "d"))
769
815
  q.shuffleEnabled = true
770
- let nextFromProp = q.nextTracks.map(\.id)
816
+ let nextFromProp = q.nextTracks.map { $0.id! }
771
817
  // Navigate forward only as many times as nextTracks reports
772
818
  // (nextTracks does not include wrapped tracks)
773
819
  var nextFromNav: [String] = []
774
820
  for _ in 0 ..< nextFromProp.count {
775
821
  _ = q.next()
776
- nextFromNav.append(q.currentTrack!.id)
822
+ nextFromNav.append(q.currentTrack!.id!)
777
823
  }
778
824
  #expect(nextFromProp == nextFromNav)
779
825
  }
@@ -786,12 +832,12 @@ struct ComputedPropertyTests {
786
832
  for _ in 0 ..< 3 {
787
833
  _ = q.next()
788
834
  }
789
- let prevFromProp = q.previousTracks.map(\.id)
835
+ let prevFromProp = q.previousTracks.map { $0.id! }
790
836
  // Navigate backward the same number of times
791
837
  var prevFromNav: [String] = []
792
838
  for _ in 0 ..< prevFromProp.count {
793
839
  _ = q.previous()
794
- prevFromNav.insert(q.currentTrack!.id, at: 0)
840
+ prevFromNav.insert(q.currentTrack!.id!, at: 0)
795
841
  }
796
842
  #expect(prevFromProp == prevFromNav)
797
843
  }
@@ -0,0 +1,156 @@
1
+ import Foundation
2
+ import MediaPlayer
3
+ import Testing
4
+
5
+ @testable import AudioBrowserTestable
6
+
7
+ /// Exercises the controller against the real `MPRemoteCommandCenter`.
8
+ ///
9
+ /// `MPRemoteCommandCenter`'s `init()` is unavailable, so `shared()` — a
10
+ /// process-wide singleton — is the only center available. The suite is
11
+ /// `.serialized` because of that shared state, and every test resets the
12
+ /// commands it touches through `reset()`.
13
+ @Suite("RemoteCommandController", .serialized)
14
+ @MainActor
15
+ struct RemoteCommandControllerTests {
16
+ final class SpyCallbacks: RemoteCommandCallbacks {
17
+ var events: [String] = []
18
+ func remotePlay() { events.append("play") }
19
+ func remotePause() { events.append("pause") }
20
+ func remoteStop() { events.append("stop") }
21
+ func remotePlayPause() { events.append("playPause") }
22
+ func remoteNext() { events.append("next") }
23
+ func remotePrevious() { events.append("previous") }
24
+ func remoteJumpForward(interval: Double) { events.append("jumpForward:\(interval)") }
25
+ func remoteJumpBackward(interval: Double) { events.append("jumpBackward:\(interval)") }
26
+ func remoteSeek(position: Double) { events.append("seek:\(position)") }
27
+ func remoteChangeRepeatMode(mode: RepeatMode) { events.append("repeat:\(mode)") }
28
+ func remoteChangeShuffleMode(enabled: Bool) { events.append("shuffle:\(enabled)") }
29
+ func remoteChangePlaybackRate(rate: Float) { events.append("rate:\(rate)") }
30
+ }
31
+
32
+ /// Clears every command on the shared center so a test starts from a known
33
+ /// state regardless of what ran before it.
34
+ private func makeController() -> (RemoteCommandController, SpyCallbacks) {
35
+ let center = MPRemoteCommandCenter.shared()
36
+ let reset = RemoteCommandController(remoteCommandCenter: center)
37
+ reset.enable(commands: RemoteCommand.all())
38
+ reset.disableAll()
39
+
40
+ let spy = SpyCallbacks()
41
+ return (RemoteCommandController(remoteCommandCenter: center, callbacks: spy), spy)
42
+ }
43
+
44
+ /// The reported bug: a changed jump interval used to disable the very command
45
+ /// it had just re-enabled, removing the skip button from the lock screen,
46
+ /// Control Center and CarPlay while `enabledCommands` still reported it on.
47
+ @Test func changedJumpIntervalKeepsCommandEnabled() {
48
+ let (controller, _) = makeController()
49
+ let center = MPRemoteCommandCenter.shared()
50
+
51
+ controller.enable(commands: [
52
+ .play, .skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15]),
53
+ ])
54
+ #expect(center.skipForwardCommand.isEnabled)
55
+
56
+ controller.enable(commands: [
57
+ .play, .skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30]),
58
+ ])
59
+
60
+ #expect(center.skipForwardCommand.isEnabled)
61
+ #expect(center.skipBackwardCommand.isEnabled)
62
+ #expect(controller.commandTargetPointers["skipForward"] != nil)
63
+ #expect(controller.commandTargetPointers["skipBackward"] != nil)
64
+ #expect(center.skipForwardCommand.preferredIntervals == [30])
65
+ #expect(center.skipBackwardCommand.preferredIntervals == [30])
66
+ }
67
+
68
+ @Test func changedPlaybackRatesKeepCommandEnabled() {
69
+ let (controller, _) = makeController()
70
+ let center = MPRemoteCommandCenter.shared()
71
+
72
+ controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0])])
73
+ controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])])
74
+
75
+ #expect(center.changePlaybackRateCommand.isEnabled)
76
+ #expect(controller.commandTargetPointers["changePlaybackRate"] != nil)
77
+ #expect(center.changePlaybackRateCommand.supportedPlaybackRates == [1.0, 1.5, 2.0])
78
+ }
79
+
80
+ @Test func removedCommandIsDisabledAndUntargeted() {
81
+ let (controller, _) = makeController()
82
+ let center = MPRemoteCommandCenter.shared()
83
+
84
+ controller.enable(commands: [.play, .pause, .next])
85
+ #expect(center.nextTrackCommand.isEnabled)
86
+
87
+ controller.enable(commands: [.play, .pause])
88
+
89
+ #expect(!center.nextTrackCommand.isEnabled)
90
+ #expect(controller.commandTargetPointers["nextTrack"] == nil)
91
+ #expect(center.playCommand.isEnabled)
92
+ #expect(controller.commandTargetPointers["play"] != nil)
93
+ }
94
+
95
+ @Test func disableAllClearsEveryTarget() {
96
+ let (controller, _) = makeController()
97
+ let center = MPRemoteCommandCenter.shared()
98
+
99
+ controller.enable(commands: [.play, .pause, .skipForward(preferredIntervals: [15])])
100
+ controller.disableAll()
101
+
102
+ #expect(controller.commandTargetPointers.isEmpty)
103
+ #expect(!center.playCommand.isEnabled)
104
+ #expect(!center.pauseCommand.isEnabled)
105
+ #expect(!center.skipForwardCommand.isEnabled)
106
+ }
107
+
108
+ /// Re-enabling the same set must not accumulate handlers: `enableRemoteCommand`
109
+ /// removes the previous target before adding the new one.
110
+ @Test func reEnablingKeepsOneTargetPerCommand() {
111
+ let (controller, _) = makeController()
112
+
113
+ controller.enable(commands: [.play, .pause])
114
+ let first = controller.commandTargetPointers["play"]
115
+ controller.enable(commands: [.play, .pause])
116
+ let second = controller.commandTargetPointers["play"]
117
+
118
+ #expect(controller.commandTargetPointers.count == 2)
119
+ #expect(first as AnyObject? !== second as AnyObject?)
120
+ }
121
+
122
+ /// `setSkipAvailability` greys out next/previous when the queue has nowhere to
123
+ /// go, and the state must survive a later `enable(commands:)`.
124
+ @Test func skipAvailabilitySurvivesReEnable() {
125
+ let (controller, _) = makeController()
126
+ let center = MPRemoteCommandCenter.shared()
127
+
128
+ controller.enable(commands: [.next, .previous])
129
+ controller.setSkipAvailability(canNext: false, canPrevious: true)
130
+ #expect(!center.nextTrackCommand.isEnabled)
131
+ #expect(center.previousTrackCommand.isEnabled)
132
+
133
+ controller.enable(commands: [.next, .previous, .play])
134
+
135
+ #expect(!center.nextTrackCommand.isEnabled)
136
+ #expect(center.previousTrackCommand.isEnabled)
137
+ }
138
+
139
+ @Test func shuffleAndRepeatStateSurviveCommandCenterSwitch() {
140
+ let (controller, _) = makeController()
141
+ let center = MPRemoteCommandCenter.shared()
142
+
143
+ controller.enable(commands: [.changeShuffleMode, .changeRepeatMode])
144
+ controller.updateShuffleMode(true)
145
+ controller.updateRepeatMode(.queue)
146
+
147
+ // Same center — switchCommandCenter no-ops, but the restore path is the
148
+ // interesting part: state is re-applied from the stored values.
149
+ controller.switchCommandCenter(center)
150
+
151
+ #expect(center.changeShuffleModeCommand.currentShuffleType == .items)
152
+ #expect(center.changeRepeatModeCommand.currentRepeatType == .all)
153
+ #expect(center.changeShuffleModeCommand.isEnabled)
154
+ #expect(controller.commandTargetPointers["changeShuffleMode"] != nil)
155
+ }
156
+ }
@@ -0,0 +1,67 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ @Suite("RemoteCommand")
7
+ struct RemoteCommandTests {
8
+ @Test func removedCommandsAreDisabled() {
9
+ let toDisable = RemoteCommand.commandsToDisable(
10
+ enabled: [.play, .pause, .next, .previous],
11
+ replacedBy: [.play, .pause],
12
+ )
13
+ #expect(toDisable.map(\.key) == ["nextTrack", "previousTrack"])
14
+ }
15
+
16
+ @Test func retainedCommandsAreNotDisabled() {
17
+ let toDisable = RemoteCommand.commandsToDisable(
18
+ enabled: [.play, .pause],
19
+ replacedBy: [.play, .pause, .stop],
20
+ )
21
+ #expect(toDisable.isEmpty)
22
+ }
23
+
24
+ /// A changed jump interval is the same command with a new associated value —
25
+ /// diffing by `==` would report it as removed and disable the button that the
26
+ /// update had just re-enabled.
27
+ @Test func changedJumpIntervalIsNotDisabled() {
28
+ let toDisable = RemoteCommand.commandsToDisable(
29
+ enabled: [.skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15])],
30
+ replacedBy: [.skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30])],
31
+ )
32
+ #expect(toDisable.isEmpty)
33
+ }
34
+
35
+ @Test func changedPlaybackRatesAreNotDisabled() {
36
+ let toDisable = RemoteCommand.commandsToDisable(
37
+ enabled: [.changePlaybackRate(supportedPlaybackRates: [1.0])],
38
+ replacedBy: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])],
39
+ )
40
+ #expect(toDisable.isEmpty)
41
+ }
42
+
43
+ @Test func everyCommandIsDisabledWhenNoneRemain() {
44
+ let toDisable = RemoteCommand.commandsToDisable(
45
+ enabled: RemoteCommand.all(),
46
+ replacedBy: [],
47
+ )
48
+ #expect(toDisable.count == RemoteCommand.all().count)
49
+ }
50
+
51
+ /// `key` is the identity used both for the diff above and for the
52
+ /// `commandTargetPointers` slot each command's handler lives in. Two cases
53
+ /// sharing a key would silently share one slot: enabling one would remove the
54
+ /// other's handler.
55
+ @Test func keysAreUnique() {
56
+ let all = RemoteCommand.all()
57
+ #expect(Set(all.map(\.key)).count == all.count)
58
+ }
59
+
60
+ @Test func droppedJumpCommandIsStillDisabled() {
61
+ let toDisable = RemoteCommand.commandsToDisable(
62
+ enabled: [.play, .skipForward(preferredIntervals: [15])],
63
+ replacedBy: [.play],
64
+ )
65
+ #expect(toDisable.map(\.key) == ["skipForward"])
66
+ }
67
+ }
@@ -1,3 +1,4 @@
1
+ import AVFoundation
1
2
  import Foundation
2
3
  import Testing
3
4
 
@@ -87,3 +88,91 @@ struct RetryManagerBudgetTests {
87
88
  #expect(manager.hasPlayed == true)
88
89
  }
89
90
  }
91
+
92
+ /// Which failures earn another attempt. The reference is Android's
93
+ /// `RetryLoadErrorHandlingPolicy.classifyError`: same HTTP set, and the same
94
+ /// default of treating an unrecognized failure as transient. Defaulting the
95
+ /// other way made `retry: true` largely inert on iOS, since AVFoundation
96
+ /// reports most stream failures as opaque CoreMedia errors, not `URLError`.
97
+ @Suite("RetryManager error classification")
98
+ @MainActor
99
+ struct RetryManagerClassificationTests {
100
+ private func makeManager() -> RetryManager {
101
+ let manager = RetryManager()
102
+ manager.updatePolicy(from: .first(true))
103
+ return manager
104
+ }
105
+
106
+ /// `AVPlayerItem.error` never carries the status, so the caller reads it off
107
+ /// the item's error log and passes it in. It outranks the opaque error.
108
+ @Test(arguments: [408, 429, 500, 502, 503, 504])
109
+ func retryableHTTPStatus_retries(status: Int) {
110
+ let opaque = NSError(domain: "CoreMediaErrorDomain", code: -12660)
111
+ #expect(makeManager().isRetryable(opaque, httpStatusCode: status) == true)
112
+ }
113
+
114
+ /// A retry cannot conjure content that isn't there, whatever the error says.
115
+ @Test(arguments: [400, 401, 403, 404, 410, 451])
116
+ func permanentHTTPStatus_doesNotRetry(status: Int) {
117
+ let transient = URLError(.timedOut)
118
+ #expect(makeManager().isRetryable(transient, httpStatusCode: status) == false)
119
+ }
120
+
121
+ @Test func transientURLError_retries() {
122
+ #expect(makeManager().isRetryable(URLError(.networkConnectionLost), httpStatusCode: nil) == true)
123
+ }
124
+
125
+ @Test func fatalURLError_doesNotRetry() {
126
+ #expect(makeManager().isRetryable(URLError(.badURL), httpStatusCode: nil) == false)
127
+ }
128
+
129
+ /// The regression this suite exists for: an opaque CoreMedia failure with no
130
+ /// status to read used to be terminal, so a station that dropped mid-song
131
+ /// never came back on iOS while Android reconnected.
132
+ @Test func opaqueCoreMediaError_retries() {
133
+ let error = NSError(domain: "CoreMediaErrorDomain", code: -12939)
134
+ #expect(makeManager().isRetryable(error, httpStatusCode: nil) == true)
135
+ }
136
+
137
+ /// `AVErrorUnknown` is the wrapper AVFoundation puts around transport
138
+ /// failures, so it must stay retryable even though it is an AVError.
139
+ @Test func avErrorUnknown_retries() {
140
+ let error = NSError(domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue)
141
+ #expect(makeManager().isRetryable(error, httpStatusCode: nil) == true)
142
+ }
143
+
144
+ @Test(arguments: [
145
+ AVError.fileFormatNotRecognized.rawValue,
146
+ AVError.failedToParse.rawValue,
147
+ AVError.decodeFailed.rawValue,
148
+ ])
149
+ func unusableMedia_doesNotRetry(code: Int) {
150
+ let error = NSError(domain: AVFoundationErrorDomain, code: code)
151
+ #expect(makeManager().isRetryable(error, httpStatusCode: nil) == false)
152
+ }
153
+
154
+ /// AVFoundation wraps the real cause rather than surfacing it, which is why
155
+ /// the classifier walks `NSUnderlyingError` instead of inspecting only the
156
+ /// outermost error.
157
+ @Test func underlyingURLError_isFound() {
158
+ let manager = makeManager()
159
+ let wrapped = NSError(
160
+ domain: AVFoundationErrorDomain,
161
+ code: AVError.unknown.rawValue,
162
+ userInfo: [NSUnderlyingErrorKey: URLError(.badURL)],
163
+ )
164
+ #expect(manager.isRetryable(wrapped, httpStatusCode: nil) == false)
165
+
166
+ let transient = NSError(
167
+ domain: AVFoundationErrorDomain,
168
+ code: AVError.unknown.rawValue,
169
+ userInfo: [NSUnderlyingErrorKey: URLError(.dnsLookupFailed)],
170
+ )
171
+ #expect(manager.isRetryable(transient, httpStatusCode: nil) == true)
172
+ }
173
+
174
+ /// Nothing to classify: no error and no status stays terminal.
175
+ @Test func nilError_doesNotRetry() {
176
+ #expect(makeManager().isRetryable(nil, httpStatusCode: nil) == false)
177
+ }
178
+ }
@@ -7,12 +7,12 @@ struct SectionScopeTests {
7
7
  }
8
8
 
9
9
  private func runSrcs(_ section: SectionScope.Section?) -> [String?]? {
10
- guard case .run(let tracks) = section else { return nil }
10
+ guard case let .run(tracks) = section else { return nil }
11
11
  return tracks.map(\.src)
12
12
  }
13
13
 
14
14
  private func imageRowSrcs(_ section: SectionScope.Section?) -> [String?]? {
15
- guard case .imageRow(let items) = section else { return nil }
15
+ guard case let .imageRow(items) = section else { return nil }
16
16
  return items.map(\.src)
17
17
  }
18
18
 
@@ -2,15 +2,15 @@
2
2
  import Foundation
3
3
  import Testing
4
4
 
5
- // Timing tests use generous margins (50ms+) around DispatchQueue scheduling
6
- // slop. While an @MainActor test awaits Task.sleep, the main queue is free to
7
- // run the manager's scheduled jobs.
5
+ // Timing tests drive a ManualSleepTimerScheduler rather than the main queue:
6
+ // every deadline below is virtual, so nothing races the real clock.
8
7
 
9
8
  @Suite("SleepTimerManager - fade scheduling")
10
9
  struct SleepTimerManagerTests {
11
10
  @Test @MainActor
12
- func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() async throws {
13
- let manager = SleepTimerManager()
11
+ func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() {
12
+ let clock = ManualSleepTimerScheduler()
13
+ let manager = SleepTimerManager(scheduler: clock)
14
14
  var fadeStarts: [TimeInterval] = []
15
15
  var completes = 0
16
16
  var cancels = 0
@@ -20,35 +20,37 @@ struct SleepTimerManagerTests {
20
20
  // Attach after set() — replacing a timer emits a defensive onFadeCancel.
21
21
  manager.onFadeCancel = { cancels += 1 }
22
22
 
23
- try await Task.sleep(nanoseconds: 50_000_000) // 0.05s — before the fade window
23
+ clock.advance(to: 0.05) // before the fade window
24
24
  #expect(fadeStarts.isEmpty)
25
25
  #expect(manager.get() != nil)
26
26
 
27
- try await Task.sleep(nanoseconds: 170_000_000) // ~0.22s — inside the fade window
27
+ clock.advance(to: 0.22) // inside the fade window
28
28
  #expect(fadeStarts == [0.15])
29
29
  #expect(completes == 0)
30
30
 
31
- try await Task.sleep(nanoseconds: 180_000_000) // ~0.40s — past the deadline
31
+ clock.advance(to: 0.4) // past the deadline
32
32
  #expect(completes == 1)
33
33
  #expect(cancels == 0)
34
34
  #expect(manager.get() == nil)
35
35
  }
36
36
 
37
37
  @Test @MainActor
38
- func fadeLongerThanTimer_clampsAndStartsImmediately() async throws {
39
- let manager = SleepTimerManager()
38
+ func fadeLongerThanTimer_clampsAndStartsImmediately() {
39
+ let clock = ManualSleepTimerScheduler()
40
+ let manager = SleepTimerManager(scheduler: clock)
40
41
  var fadeStarts: [TimeInterval] = []
41
42
  manager.onFadeStart = { fadeStarts.append($0) }
42
43
 
43
44
  manager.set(seconds: 0.2, fadeDuration: 5)
44
45
 
45
- try await Task.sleep(nanoseconds: 100_000_000) // 0.1s — fade should have begun
46
+ clock.advance(to: 0.1) // fade should have begun
46
47
  #expect(fadeStarts == [0.2])
47
48
  }
48
49
 
49
50
  @Test @MainActor
50
- func clear_emitsFadeCancel_andCancelsScheduledFade() async throws {
51
- let manager = SleepTimerManager()
51
+ func clear_emitsFadeCancel_andCancelsScheduledFade() {
52
+ let clock = ManualSleepTimerScheduler()
53
+ let manager = SleepTimerManager(scheduler: clock)
52
54
  var fadeStarts = 0
53
55
  manager.set(seconds: 0.2, fadeDuration: 0.15)
54
56
  var cancels = 0
@@ -58,13 +60,13 @@ struct SleepTimerManagerTests {
58
60
  #expect(manager.clear())
59
61
  #expect(cancels == 1)
60
62
 
61
- try await Task.sleep(nanoseconds: 300_000_000) // well past the would-be deadline
63
+ clock.advance(to: 0.3) // well past the would-be deadline
62
64
  #expect(fadeStarts == 0)
63
65
  }
64
66
 
65
67
  @Test @MainActor
66
68
  func replacingTimer_emitsFadeCancel() {
67
- let manager = SleepTimerManager()
69
+ let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
68
70
  manager.set(seconds: 60, fadeDuration: 10)
69
71
  var cancels = 0
70
72
  manager.onFadeCancel = { cancels += 1 }
@@ -76,7 +78,7 @@ struct SleepTimerManagerTests {
76
78
 
77
79
  @Test @MainActor
78
80
  func switchingToEndOfTrack_emitsFadeCancel() {
79
- let manager = SleepTimerManager()
81
+ let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
80
82
  manager.set(seconds: 60, fadeDuration: 10)
81
83
  var cancels = 0
82
84
  manager.onFadeCancel = { cancels += 1 }
@@ -88,8 +90,9 @@ struct SleepTimerManagerTests {
88
90
  }
89
91
 
90
92
  @Test @MainActor
91
- func noFadeDuration_schedulesNoFade() async throws {
92
- let manager = SleepTimerManager()
93
+ func noFadeDuration_schedulesNoFade() {
94
+ let clock = ManualSleepTimerScheduler()
95
+ let manager = SleepTimerManager(scheduler: clock)
93
96
  var fadeStarts = 0
94
97
  var completes = 0
95
98
  manager.onFadeStart = { _ in fadeStarts += 1 }
@@ -97,7 +100,7 @@ struct SleepTimerManagerTests {
97
100
 
98
101
  manager.set(seconds: 0.1)
99
102
 
100
- try await Task.sleep(nanoseconds: 200_000_000)
103
+ clock.advance(to: 0.2)
101
104
  #expect(fadeStarts == 0)
102
105
  #expect(completes == 1)
103
106
  }
@@ -0,0 +1,47 @@
1
+ @testable import AudioBrowserTestable
2
+ import Foundation
3
+
4
+ /// A job handed back by `ManualSleepTimerScheduler`.
5
+ private final class ManualJob: SleepTimerJob {
6
+ nonisolated(unsafe) var isCancelled = false
7
+ func cancel() { isCancelled = true }
8
+ }
9
+
10
+ /// Deterministic stand-in for the main-queue scheduler: jobs fire only when the
11
+ /// test advances the clock, so an assertion can never lose a race against a
12
+ /// real deadline on a loaded CI runner.
13
+ @MainActor
14
+ final class ManualSleepTimerScheduler: SleepTimerScheduling {
15
+ private struct Entry {
16
+ let deadline: TimeInterval
17
+ let job: ManualJob
18
+ let work: @MainActor () -> Void
19
+ }
20
+
21
+ private var entries: [Entry] = []
22
+ private(set) var now: TimeInterval = 0
23
+
24
+ func schedule(
25
+ after delay: TimeInterval,
26
+ _ work: @escaping @Sendable @MainActor () -> Void,
27
+ ) -> any SleepTimerJob {
28
+ let job = ManualJob()
29
+ entries.append(Entry(deadline: now + delay, job: job, work: work))
30
+ return job
31
+ }
32
+
33
+ /// Moves the virtual clock to `time`, firing every due job in deadline order.
34
+ /// Jobs run one at a time so a job that schedules or cancels another (the
35
+ /// fade landing before completion) sees the same ordering it would in
36
+ /// production.
37
+ func advance(to time: TimeInterval) {
38
+ now = time
39
+ while let next = entries
40
+ .filter({ !$0.job.isCancelled && $0.deadline <= now })
41
+ .min(by: { $0.deadline < $1.deadline })
42
+ {
43
+ entries.removeAll { $0.job === next.job }
44
+ next.work()
45
+ }
46
+ }
47
+ }
@@ -209,7 +209,7 @@ final class MockRetryHandling: RetryHandling {
209
209
  var attemptRetryDelayNs: UInt64 = 0
210
210
  var resetCallCount = 0
211
211
 
212
- func isRetryable(_: Error?) -> Bool { isRetryableResult }
212
+ func isRetryable(_: Error?, httpStatusCode _: Int?) -> Bool { isRetryableResult }
213
213
  func attemptRetry(startFromCurrentTime _: Bool) async -> Bool {
214
214
  if attemptRetryDelayNs > 0 {
215
215
  try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
@@ -5,11 +5,11 @@ import Testing
5
5
  struct TabBarEntriesTests {
6
6
  private func tab(
7
7
  _ title: String, url: String, artwork: String? = nil,
8
- artworkSource: ImageSource? = nil, artist: String? = nil
8
+ artworkSource: ImageSource? = nil, artist: String? = nil,
9
9
  ) -> Track {
10
10
  Track(
11
11
  id: url, url: url, title: title, artist: artist, artwork: artwork.map { .first($0) },
12
- artworkSource: artworkSource
12
+ artworkSource: artworkSource,
13
13
  )
14
14
  }
15
15
 
@@ -22,7 +22,7 @@ struct TabBarEntriesTests {
22
22
  #expect(
23
23
  !TabBarEntries.same(
24
24
  [tab("Explore", url: "/explore")],
25
- [tab("Verkennen", url: "/explore")]
25
+ [tab("Verkennen", url: "/explore")],
26
26
  ))
27
27
  }
28
28
 
@@ -30,7 +30,7 @@ struct TabBarEntriesTests {
30
30
  #expect(
31
31
  !TabBarEntries.same(
32
32
  [tab("Explore", url: "/explore")],
33
- [tab("Explore", url: "/discover")]
33
+ [tab("Explore", url: "/discover")],
34
34
  ))
35
35
  }
36
36
 
@@ -38,7 +38,7 @@ struct TabBarEntriesTests {
38
38
  #expect(
39
39
  !TabBarEntries.same(
40
40
  [tab("Explore", url: "/explore", artwork: "sf:globe")],
41
- [tab("Explore", url: "/explore", artwork: "sf:map")]
41
+ [tab("Explore", url: "/explore", artwork: "sf:map")],
42
42
  ))
43
43
  }
44
44
 
@@ -46,7 +46,7 @@ struct TabBarEntriesTests {
46
46
  #expect(
47
47
  !TabBarEntries.same(
48
48
  [tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/1.png"))],
49
- [tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/2.png"))]
49
+ [tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/2.png"))],
50
50
  ))
51
51
  }
52
52
 
@@ -65,7 +65,7 @@ struct TabBarEntriesTests {
65
65
  #expect(
66
66
  TabBarEntries.same(
67
67
  [tab("Explore", url: "/explore", artist: "a")],
68
- [tab("Explore", url: "/explore", artist: "b")]
68
+ [tab("Explore", url: "/explore", artist: "b")],
69
69
  ))
70
70
  }
71
71
  }
@@ -463,6 +463,11 @@ class TrackPlayer {
463
463
 
464
464
  func destroy() {
465
465
  clear()
466
+ // An AVPlayer retains its time observers: one left registered is still attached when the
467
+ // player is released, and outlives this object.
468
+ playerTimeObserver.unregisterForBoundaryTimeEvents()
469
+ playerObserver.stopObserving()
470
+ stopObservingAVPlayerItem()
466
471
  remoteCommandController.disableAll()
467
472
  }
468
473
 
@@ -753,7 +758,7 @@ extension TrackPlayer {
753
758
  repeatMode: repeatMode.persistedString,
754
759
  shuffleEnabled: shuffleEnabled,
755
760
  playbackSpeed: rate,
756
- )
761
+ ),
757
762
  )
758
763
  }
759
764