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

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 (284) hide show
  1. package/README.md +22 -18
  2. package/android/build.gradle +7 -5
  3. package/android/gradle.properties +1 -1
  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 +21 -6
  23. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +109 -0
  24. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +16 -2
  25. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +59 -3
  26. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  27. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  28. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  29. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
  30. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  31. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  32. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  33. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  34. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  35. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  36. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +46 -0
  37. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +113 -0
  38. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  39. package/ios/Browser/BrowserManager.swift +2 -27
  40. package/ios/Browser/SearchDrillIn.swift +1 -1
  41. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  42. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  43. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  44. package/ios/HybridAudioBrowser.swift +87 -56
  45. package/ios/Model/RemoteCommand.swift +18 -0
  46. package/ios/Model/TrackPlayerError.swift +2 -2
  47. package/ios/Option/SessionCategories.swift +3 -2
  48. package/ios/Player/MediaIntentCriteria.swift +4 -4
  49. package/ios/Player/MediaLoader.swift +3 -1
  50. package/ios/Player/PlaybackCoordinator.swift +4 -1
  51. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  52. package/ios/Player/QueueManager.swift +10 -3
  53. package/ios/Player/RetryManager.swift +79 -18
  54. package/ios/Player/ShuffleOrder.swift +10 -0
  55. package/ios/Player/SleepTimerManager.swift +43 -15
  56. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  57. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  58. package/ios/Tests/LRUCacheTests.swift +111 -0
  59. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  60. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  61. package/ios/Tests/MediaLoaderTests.swift +10 -13
  62. package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
  63. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  64. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  65. package/ios/Tests/QueueManagerTests.swift +46 -0
  66. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  67. package/ios/Tests/RemoteCommandTests.swift +67 -0
  68. package/ios/Tests/RetryManagerTests.swift +89 -0
  69. package/ios/Tests/SectionScopeTests.swift +2 -2
  70. package/ios/Tests/SleepTimerManagerTests.swift +22 -19
  71. package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
  72. package/ios/Tests/Support/TestTypes.swift +1 -1
  73. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  74. package/ios/TrackPlayer.swift +6 -1
  75. package/ios/TrackPlayerCallbacks.swift +5 -38
  76. package/ios/Util/LRUCache.swift +4 -1
  77. package/lib/commonjs/AudioBrowser.js +0 -11
  78. package/lib/commonjs/AudioBrowser.js.map +1 -1
  79. package/lib/commonjs/CLAUDE.md +52 -0
  80. package/lib/commonjs/features/browser.js +1 -1
  81. package/lib/commonjs/features/browser.js.map +1 -1
  82. package/lib/commonjs/features/carConnection.js +1 -1
  83. package/lib/commonjs/features/equalizer.js +1 -1
  84. package/lib/commonjs/features/errors.js +3 -3
  85. package/lib/commonjs/features/favorites.js +1 -1
  86. package/lib/commonjs/features/gate.js +16 -1
  87. package/lib/commonjs/features/gate.js.map +1 -1
  88. package/lib/commonjs/features/metadata.js +20 -3
  89. package/lib/commonjs/features/metadata.js.map +1 -1
  90. package/lib/commonjs/features/network.js +1 -1
  91. package/lib/commonjs/features/nowPlaying.js +1 -1
  92. package/lib/commonjs/features/output.js +1 -1
  93. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  94. package/lib/commonjs/features/playback/playing.js +1 -1
  95. package/lib/commonjs/features/playback/progress.js +1 -1
  96. package/lib/commonjs/features/playback/state.js +1 -1
  97. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  98. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  99. package/lib/commonjs/features/playback/volume.js +1 -1
  100. package/lib/commonjs/features/player/options.js +48 -34
  101. package/lib/commonjs/features/player/options.js.map +1 -1
  102. package/lib/commonjs/features/player/setup.js +15 -8
  103. package/lib/commonjs/features/player/setup.js.map +1 -1
  104. package/lib/commonjs/features/player/validateOptions.js +21 -0
  105. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  106. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  107. package/lib/commonjs/features/queue/queue.js +2 -2
  108. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  109. package/lib/commonjs/features/queue/shuffle.js +1 -1
  110. package/lib/commonjs/features/remoteControls.js +13 -92
  111. package/lib/commonjs/features/remoteControls.js.map +1 -1
  112. package/lib/commonjs/features/sleepTimer.js +13 -1
  113. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  114. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  115. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  116. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  117. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  118. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  119. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  120. package/lib/commonjs/web/browser/BrowserManager.js +1 -1
  121. package/lib/module/AudioBrowser.js +1 -1
  122. package/lib/module/AudioBrowser.js.map +1 -1
  123. package/lib/module/CLAUDE.md +52 -0
  124. package/lib/module/features/browser.js +1 -1
  125. package/lib/module/features/browser.js.map +1 -1
  126. package/lib/module/features/carConnection.js +1 -1
  127. package/lib/module/features/equalizer.js +1 -1
  128. package/lib/module/features/errors.js +3 -3
  129. package/lib/module/features/favorites.js +1 -1
  130. package/lib/module/features/gate.js +16 -1
  131. package/lib/module/features/gate.js.map +1 -1
  132. package/lib/module/features/metadata.js +20 -3
  133. package/lib/module/features/metadata.js.map +1 -1
  134. package/lib/module/features/network.js +1 -1
  135. package/lib/module/features/nowPlaying.js +1 -1
  136. package/lib/module/features/output.js +1 -1
  137. package/lib/module/features/playback/playWhenReady.js +1 -1
  138. package/lib/module/features/playback/playing.js +1 -1
  139. package/lib/module/features/playback/progress.js +1 -1
  140. package/lib/module/features/playback/state.js +1 -1
  141. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  142. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  143. package/lib/module/features/playback/volume.js +1 -1
  144. package/lib/module/features/player/options.js +47 -32
  145. package/lib/module/features/player/options.js.map +1 -1
  146. package/lib/module/features/player/setup.js +14 -7
  147. package/lib/module/features/player/setup.js.map +1 -1
  148. package/lib/module/features/player/validateOptions.js +17 -0
  149. package/lib/module/features/player/validateOptions.js.map +1 -0
  150. package/lib/module/features/queue/activeTrack.js +1 -1
  151. package/lib/module/features/queue/queue.js +2 -2
  152. package/lib/module/features/queue/repeatMode.js +1 -1
  153. package/lib/module/features/queue/shuffle.js +1 -1
  154. package/lib/module/features/remoteControls.js +13 -92
  155. package/lib/module/features/remoteControls.js.map +1 -1
  156. package/lib/module/features/sleepTimer.js +13 -1
  157. package/lib/module/features/sleepTimer.js.map +1 -1
  158. package/lib/module/test-utils/reactNativeStub.js +58 -0
  159. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  160. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  161. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  162. package/lib/module/web/NativeAudioBrowser.js +1 -1
  163. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  164. package/lib/module/web/browser/BrowserManager.js +1 -1
  165. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  166. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  167. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  168. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  169. package/lib/typescript/src/features/errors.d.ts +3 -3
  170. package/lib/typescript/src/features/favorites.d.ts +1 -1
  171. package/lib/typescript/src/features/gate.d.ts +18 -2
  172. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  173. package/lib/typescript/src/features/metadata.d.ts +20 -3
  174. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  175. package/lib/typescript/src/features/network.d.ts +1 -1
  176. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  177. package/lib/typescript/src/features/output.d.ts +1 -1
  178. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  179. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  180. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  181. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  182. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  183. package/lib/typescript/src/features/player/options.d.ts +87 -37
  184. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  185. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  186. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  187. package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
  188. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  189. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  190. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  191. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  192. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  193. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  194. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  195. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  196. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  197. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  198. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  199. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  200. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  201. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  202. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  203. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  204. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
  205. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  206. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  207. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  208. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  209. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  210. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  211. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  212. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  213. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  214. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  215. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  216. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  217. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  218. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  219. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  220. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  221. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  222. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
  223. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  224. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  225. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  226. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  227. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  228. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  229. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  230. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  231. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  232. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  233. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  234. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  235. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  236. package/package.json +10 -3
  237. package/src/AudioBrowser.ts +1 -1
  238. package/src/CLAUDE.md +52 -0
  239. package/src/features/battery.test.ts +79 -0
  240. package/src/features/browser.ts +1 -1
  241. package/src/features/carConnection.ts +1 -1
  242. package/src/features/equalizer.ts +1 -1
  243. package/src/features/errors.ts +3 -3
  244. package/src/features/favorites.ts +1 -1
  245. package/src/features/gate.ts +16 -1
  246. package/src/features/hook-wiring.test.ts +254 -0
  247. package/src/features/metadata.ts +20 -3
  248. package/src/features/network.ts +1 -1
  249. package/src/features/nowPlaying.ts +1 -1
  250. package/src/features/output.ts +1 -1
  251. package/src/features/playback/playWhenReady.ts +1 -1
  252. package/src/features/playback/playing.ts +1 -1
  253. package/src/features/playback/progress.test.ts +169 -0
  254. package/src/features/playback/progress.ts +1 -1
  255. package/src/features/playback/state.ts +1 -1
  256. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  257. package/src/features/playback/trackPlaybackTime.ts +0 -5
  258. package/src/features/playback/volume.ts +1 -1
  259. package/src/features/player/options.ts +99 -57
  260. package/src/features/player/setup.test.ts +12 -4
  261. package/src/features/player/setup.ts +19 -9
  262. package/src/features/player/validateOptions.ts +19 -0
  263. package/src/features/queue/activeTrack.ts +1 -1
  264. package/src/features/queue/queue.ts +2 -2
  265. package/src/features/queue/repeatMode.ts +1 -1
  266. package/src/features/queue/shuffle.ts +1 -1
  267. package/src/features/remoteControls.ts +13 -92
  268. package/src/features/sleepTimer.test.ts +172 -0
  269. package/src/features/sleepTimer.ts +9 -1
  270. package/src/public-surface.test.ts +107 -0
  271. package/src/specs/audio-browser.nitro.ts +1 -1
  272. package/src/test-utils/reactNativeStub.ts +60 -0
  273. package/src/utils/LazyNativeEmitter.ts +9 -6
  274. package/src/utils/useDebug.test.ts +182 -0
  275. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  276. package/src/web/NativeAudioBrowser.ts +1 -1
  277. package/src/web/browser/BrowserManager.ts +1 -1
  278. package/ios/Model/MediaURL.swift +0 -67
  279. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  280. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  281. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  282. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  283. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  284. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -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
 
@@ -9,10 +9,11 @@ import NitroModules
9
9
  /// Note: Method names are prefixed to avoid conflicts with JS callback property names.
10
10
  /// All callbacks are called from the main actor since TrackPlayer is @MainActor.
11
11
  ///
12
- /// Extends PlaybackCoordinatorCallbacks for the state machine / coordinator callbacks,
13
- /// then adds metadata, seek, remote, and configuration callbacks that are TrackPlayer-specific.
12
+ /// Extends PlaybackCoordinatorCallbacks for the state machine / coordinator callbacks
13
+ /// and RemoteCommandCallbacks for the remote-command events, then adds metadata,
14
+ /// seek, and configuration callbacks that are TrackPlayer-specific.
14
15
  @MainActor
15
- protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
16
+ protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks, RemoteCommandCallbacks {
16
17
  // MARK: - Metadata Events
17
18
 
18
19
  /// Called when common metadata is received.
@@ -34,32 +35,7 @@ protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
34
35
 
35
36
  // MARK: - Remote Control Events
36
37
 
37
- /// Called when play is triggered remotely.
38
- func remotePlay()
39
-
40
- /// Called when pause is triggered remotely.
41
- func remotePause()
42
-
43
- /// Called when stop is triggered remotely.
44
- func remoteStop()
45
-
46
- /// Called when toggle play/pause is triggered remotely.
47
- func remotePlayPause()
48
-
49
- /// Called when next is triggered remotely.
50
- func remoteNext()
51
-
52
- /// Called when previous is triggered remotely.
53
- func remotePrevious()
54
-
55
- /// Called when jump forward is triggered remotely.
56
- func remoteJumpForward(interval: Double)
57
-
58
- /// Called when jump backward is triggered remotely.
59
- func remoteJumpBackward(interval: Double)
60
-
61
- /// Called when seek is triggered remotely.
62
- func remoteSeek(position: Double)
38
+ // The command-center events live in RemoteCommandCallbacks, inherited above.
63
39
 
64
40
  /// Called when change playback position is triggered remotely.
65
41
  func remoteChangePlaybackPosition(position: Double)
@@ -70,15 +46,6 @@ protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
70
46
  /// Called when play from search is triggered remotely.
71
47
  func remotePlaySearch(query: String)
72
48
 
73
- /// Called when repeat mode change is triggered remotely (CarPlay/lock screen).
74
- func remoteChangeRepeatMode(mode: RepeatMode)
75
-
76
- /// Called when shuffle mode change is triggered remotely (CarPlay/lock screen).
77
- func remoteChangeShuffleMode(enabled: Bool)
78
-
79
- /// Called when playback rate change is triggered remotely (CarPlay/lock screen).
80
- func remoteChangePlaybackRate(rate: Float)
81
-
82
49
  // MARK: - Configuration Events
83
50
 
84
51
  /// Called when options are changed.
@@ -11,7 +11,10 @@ final class LRUCache<Key: Hashable, Value> {
11
11
  private final class Node {
12
12
  let key: Key
13
13
  var value: Value
14
- var prev: Node?
14
+ /// Weak: with both directions strong, any two adjacent nodes form a reference cycle, so the
15
+ /// whole chain outlives `clear()` and the cache's own deinit. The list is kept alive from
16
+ /// `head` forwards.
17
+ weak var prev: Node?
15
18
  var next: Node?
16
19
 
17
20
  init(key: Key, value: Value) {