react-native-queue-player 1.2.0 → 2.0.1

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 (362) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/consumer-rules.pro +1 -1
  4. package/android/src/main/cpp/airplay2_control.c +4 -4
  5. package/android/src/main/cpp/airplay2_control.h +4 -4
  6. package/android/src/main/cpp/airplay2_jni.cpp +3 -19
  7. package/android/src/main/cpp/airplay2_pair.c +7 -7
  8. package/android/src/main/cpp/airplay2_pair.h +2 -2
  9. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  10. package/android/src/main/cpp/airplay2_session.c +2 -2
  11. package/android/src/main/cpp/airplay_jni.cpp +8 -69
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +111 -29
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +78 -30
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +13 -5
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +985 -276
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +5 -4
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +4 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +1 -1
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  61. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  62. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  63. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  64. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  65. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +17 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +17 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +84 -18
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +2 -2
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +1 -1
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +2 -1
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +2 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +122 -38
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +291 -27
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +2 -2
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  114. package/app.plugin.js +17 -1
  115. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  116. package/ios/AVQueueBuilder.swift +120 -85
  117. package/ios/ArtworkLoader.swift +0 -7
  118. package/ios/ArtworkResolver.swift +22 -4
  119. package/ios/AssetReplayGainReader.swift +118 -0
  120. package/ios/AudioSession.swift +67 -16
  121. package/ios/AudioTapProvider.swift +284 -149
  122. package/ios/CarPlayBridge.swift +0 -12
  123. package/ios/CarPlayCoordinator.swift +18 -2
  124. package/ios/CarPlaySceneDelegate.swift +2 -2
  125. package/ios/Cast/AirPlayRouteState.swift +9 -8
  126. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  127. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  128. package/ios/Cast/Core/CastSession.swift +2 -2
  129. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  130. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  131. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  132. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  133. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  134. package/ios/CastManager.swift +1 -1
  135. package/ios/CrossfadeEngine.swift +672 -226
  136. package/ios/EQTap.swift +20 -17
  137. package/ios/Equalizer.swift +1 -1
  138. package/ios/FLACStreamInfo.swift +65 -0
  139. package/ios/GaplessEngine.swift +57 -30
  140. package/ios/InputGuards.swift +35 -5
  141. package/ios/LookaheadCache.swift +229 -58
  142. package/ios/LookaheadCachePrefetcher.swift +48 -36
  143. package/ios/MediaResponseCheck.swift +122 -0
  144. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  145. package/ios/NetworkRecoveryPolicy.swift +32 -0
  146. package/ios/NowPlayingFormatExtractor.swift +25 -18
  147. package/ios/NowPlayingInfo.swift +84 -48
  148. package/ios/OutputRouteMonitor.swift +16 -3
  149. package/ios/PendingBrowseRequests.swift +1 -1
  150. package/ios/PlaceholderArtwork.swift +9 -5
  151. package/ios/PlaybackEngine.swift +165 -76
  152. package/ios/PlaybackErrorMapping.swift +3 -3
  153. package/ios/PlaybackModeStateMachine.swift +2 -2
  154. package/ios/PlaybackNetworkMonitor.swift +73 -0
  155. package/ios/PlayerStateDerivation.swift +6 -6
  156. package/ios/QueueMutationArithmetic.swift +25 -0
  157. package/ios/QueueSkipArithmetic.swift +5 -3
  158. package/ios/QueueWindowArithmetic.swift +152 -0
  159. package/ios/ReadThroughServer.swift +283 -0
  160. package/ios/RemoteCommands.swift +44 -4
  161. package/ios/ReplayGainData.swift +22 -3
  162. package/ios/ReplayGainExtractor.swift +70 -27
  163. package/ios/Siri/VoiceDonation.swift +14 -9
  164. package/ios/StreamingBitrateProbe.swift +22 -68
  165. package/ios/SuppliedReplayGain.swift +83 -0
  166. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  167. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  168. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  169. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  170. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  171. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  172. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  173. package/ios/Tests/CastNowPlayingControllerTests.swift +1 -1
  174. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  175. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  176. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  177. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  178. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  179. package/ios/Tests/EngineSwapTests.swift +91 -0
  180. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  181. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  182. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  183. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  184. package/ios/Tests/InputGuardsTests.swift +41 -0
  185. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  186. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  187. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  188. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  189. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  190. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  191. package/ios/Tests/MediaResponseCheckTests.swift +128 -0
  192. package/ios/Tests/MutationDeferralTests.swift +220 -0
  193. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  194. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  195. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  196. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  197. package/ios/Tests/PlayerFixtures.swift +48 -0
  198. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  199. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  200. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  201. package/ios/Tests/QueueStateTests.swift +3 -1
  202. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  203. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  204. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  205. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  206. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  207. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  208. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  209. package/ios/Tests/RetryRecoveryTests.swift +418 -0
  210. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  211. package/ios/Tests/SkipIndexTests.swift +32 -2
  212. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  213. package/ios/Tests/StallRecoveryTests.swift +97 -0
  214. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  215. package/ios/Tests/TopUpWindowGateTests.swift +441 -0
  216. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  217. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  218. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  219. package/ios/Tests/TrackPlayerEndVerdictTests.swift +206 -0
  220. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  221. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  222. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  223. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  224. package/ios/TrackPlayer+Automotive.swift +101 -0
  225. package/ios/TrackPlayer+Cache.swift +232 -0
  226. package/ios/TrackPlayer+Config.swift +291 -0
  227. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  228. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  229. package/ios/TrackPlayer+EventsDispatch.swift +1042 -0
  230. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  231. package/ios/TrackPlayer+Lifecycle.swift +982 -0
  232. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  233. package/ios/TrackPlayer+Queue.swift +951 -0
  234. package/ios/TrackPlayer+Recovery.swift +213 -0
  235. package/ios/TrackPlayer+Skip.swift +465 -0
  236. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  237. package/ios/TrackPlayer+State.swift +108 -0
  238. package/ios/TrackPlayer+Threading.swift +135 -0
  239. package/ios/TrackPlayer+Transport.swift +272 -0
  240. package/ios/TrackPlayer+Window.swift +227 -0
  241. package/ios/TrackPlayer.swift +331 -4619
  242. package/ios/Visualizer.swift +6 -5
  243. package/ios/tests-harness/Podfile +1 -1
  244. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  245. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  246. package/lib/module/hooks/useActiveTrack.js +29 -22
  247. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  248. package/lib/module/hooks/useCast.js +8 -26
  249. package/lib/module/hooks/useCast.js.map +1 -1
  250. package/lib/module/hooks/useEqualizer.js +19 -12
  251. package/lib/module/hooks/useEqualizer.js.map +1 -1
  252. package/lib/module/hooks/useLookaheadCache.js +3 -7
  253. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  254. package/lib/module/hooks/useQueue.js +66 -19
  255. package/lib/module/hooks/useQueue.js.map +1 -1
  256. package/lib/module/index.js +6 -3
  257. package/lib/module/index.js.map +1 -1
  258. package/lib/module/queueDelta.js +41 -0
  259. package/lib/module/queueDelta.js.map +1 -0
  260. package/lib/module/types.js +28 -5
  261. package/lib/module/types.js.map +1 -1
  262. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  263. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  264. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  265. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  266. package/lib/typescript/hooks/useCast.d.ts +6 -1
  267. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  269. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  271. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  272. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  273. package/lib/typescript/index.d.ts +2 -1
  274. package/lib/typescript/index.d.ts.map +1 -1
  275. package/lib/typescript/queueDelta.d.ts +10 -0
  276. package/lib/typescript/queueDelta.d.ts.map +1 -0
  277. package/lib/typescript/types.d.ts +168 -12
  278. package/lib/typescript/types.d.ts.map +1 -1
  279. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  280. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  281. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  282. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  283. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  284. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  285. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  286. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +0 -2
  287. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +0 -2
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +0 -2
  289. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  290. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  291. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  292. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/{Func_void_std__vector_TrackItem__double_QueueChangeReason.kt → Func_void_QueueChangeDelta_double_QueueChangeReason.kt} +14 -16
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  304. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  320. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  321. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  322. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  323. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  324. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  325. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  326. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  327. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  328. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  329. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  330. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  331. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  332. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  333. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  334. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  335. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  336. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  337. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  338. package/package.json +8 -8
  339. package/src/TrackPlayer.nitro.ts +53 -27
  340. package/src/hooks/useActiveTrack.ts +29 -22
  341. package/src/hooks/useCast.ts +14 -15
  342. package/src/hooks/useEqualizer.ts +19 -12
  343. package/src/hooks/useLookaheadCache.ts +3 -7
  344. package/src/hooks/useQueue.ts +66 -17
  345. package/src/index.ts +13 -3
  346. package/src/queueDelta.ts +41 -0
  347. package/src/types.ts +169 -12
  348. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  349. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  350. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  351. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  352. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  353. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  354. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  355. package/ios/MetadataReader.swift +0 -555
  356. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  357. package/ios/Tests/MetadataReaderTests.swift +0 -603
  358. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  359. package/ios/Tests/VoiceDonationTests.swift +0 -26
  360. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  361. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  362. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -41,7 +41,9 @@ class TrackPlayerEventsTest {
41
41
  private fun track(id: String) = TrackItem(
42
42
  id = id, url = "https://example.com/$id.m4a",
43
43
  title = null, artist = null, album = null,
44
- duration = null, artworkUrl = null, extras = null
44
+ duration = null, artworkUrl = null, extras = null,
45
+ replayGainTrackGain = null, replayGainTrackPeak = null,
46
+ replayGainAlbumGain = null, replayGainAlbumPeak = null
45
47
  )
46
48
 
47
49
  private lateinit var serviceHandle: PlaybackServiceTestHandle
@@ -51,7 +53,7 @@ class TrackPlayerEventsTest {
51
53
  player = TrackPlayer()
52
54
  serviceHandle = bindPlaybackServiceForTest(player, context())
53
55
  player.configureInternal(
54
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
56
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, voiceVocabularyDonationEnabled = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
55
57
  )
56
58
  }
57
59
 
@@ -100,44 +102,118 @@ class TrackPlayerEventsTest {
100
102
  assertEquals(0.0, snap.buffered, 0.001)
101
103
  }
102
104
 
105
+ // --- onStateChange / onError / onQueueEnd registration
106
+
103
107
  @Test
104
- fun `emitProgressTick is a no-op when no subscriber exists`() {
105
- // Just verify it does not throw.
106
- player.emitProgressTick()
107
- }
108
+ fun `onStateChange delivers to a subscriber and stops after unsubscribe`() {
109
+ var fired = 0
110
+ val unsubscribe = player.onStateChange { _, _ -> fired += 1 }
108
111
 
109
- // --- onStateChange / onError / onQueueEnd registration
112
+ player.emitRemoteState(PlayerState.PAUSED, StateChangeReason.SYSTEM)
113
+ assertEquals(1, fired)
114
+
115
+ unsubscribe()
116
+ // A different state, so the only thing between it and the listener is
117
+ // the unsubscribe — a repeat of the same one would be deduped.
118
+ player.emitRemoteState(PlayerState.PLAYING, StateChangeReason.SYSTEM)
119
+ assertEquals("nothing reaches a removed subscriber", 1, fired)
120
+ }
110
121
 
111
122
  @Test
112
- fun `onStateChange registration stores and unsubscribe clears the callback`() {
113
- var fireCount = 0
114
- val unsubscribe = player.onStateChange { _, _ -> fireCount += 1 }
123
+ fun `onError delivers to a subscriber and stops after unsubscribe`() {
124
+ player.setQueueInternal((1..2).map { track("t$it") })
125
+ player.currentTrackIndex = 1
126
+ var fired = 0
127
+ val unsubscribe = player.onError { fired += 1 }
128
+
129
+ // A failure on a track that is not playing is reported straight away.
130
+ player.onError(serviceHandle.engine, networkFailure(), 0)
131
+ assertEquals(1, fired)
115
132
 
116
- // No way to force the Player.Listener onto READY without a
117
- // real media pipeline, but we can unregister + verify no
118
- // leftover reference causes a crash when destroyInternal runs.
119
133
  unsubscribe()
120
- assertEquals(0, fireCount)
134
+ // A different failure, so the only thing between it and the listener
135
+ // is the unsubscribe — a repeat of the same one would be deduped.
136
+ player.onError(serviceHandle.engine, decodingFailure(), 0)
137
+ assertEquals("nothing reaches a removed subscriber", 1, fired)
121
138
  }
122
139
 
123
140
  @Test
124
- fun `onError registration stores and unsubscribe clears the callback`() {
125
- val unsubscribe = player.onError { }
141
+ fun `onQueueEnd delivers to a subscriber and stops after unsubscribe`() {
142
+ player.setQueueInternal((1..2).map { track("t$it") })
143
+ var fired = 0
144
+ val unsubscribe = player.onQueueEnd { fired += 1 }
145
+
146
+ player.maybeEmitQueueEnd(androidx.media3.common.Player.STATE_ENDED)
147
+ assertEquals(1, fired)
148
+
126
149
  unsubscribe()
127
- // No-op sanity — callback slot is cleared; destroy must not
128
- // fire a spurious error call.
150
+ player.maybeEmitQueueEnd(androidx.media3.common.Player.STATE_ENDED)
151
+ assertEquals("nothing reaches a removed subscriber", 1, fired)
129
152
  }
130
153
 
154
+ /** Queue end is announced as ENDED / QUEUE_END, and the pause that parks the
155
+ * engine afterwards leaves no reason behind for the next emit. */
131
156
  @Test
132
- fun `onQueueEnd registration stores and unsubscribe clears the callback`() {
133
- val unsubscribe = player.onQueueEnd { }
157
+ fun `queue end announces ENDED with the queue-end reason and stamps nothing after`() {
158
+ player.setQueueInternal((1..2).map { track("t$it") })
159
+ val emits = mutableListOf<Pair<PlayerState, StateChangeReason>>()
160
+ val unsubscribe = player.onStateChange { state, reason -> emits.add(state to reason) }
161
+
162
+ player.maybeEmitQueueEnd(androidx.media3.common.Player.STATE_ENDED)
134
163
  unsubscribe()
164
+
165
+ assertEquals(PlayerState.ENDED to StateChangeReason.QUEUE_END, emits.last())
166
+ assertEquals("the parking pause's USER stamp must not survive", null, player.pendingStateChangeReason)
135
167
  }
136
168
 
137
169
  @Test
138
- fun `onTrackChange registration stores and unsubscribe clears the callback`() {
139
- val unsubscribe = player.onTrackChange { _, _, _, _ -> }
170
+ fun `onTrackChange delivers to a subscriber and stops after unsubscribe`() {
171
+ player.setQueueInternal((1..3).map { track("t$it") })
172
+ var fired = 0
173
+ val unsubscribe = player.onTrackChange { _, _, _, _ -> fired += 1 }
174
+
175
+ player.emitRemoteTrackChange(1)
176
+ assertEquals(1, fired)
177
+
140
178
  unsubscribe()
179
+ // A different index, so the only thing between it and the listener is
180
+ // the unsubscribe — a repeat of the current index returns early.
181
+ player.emitRemoteTrackChange(2)
182
+ assertEquals("nothing reaches a removed subscriber", 1, fired)
183
+ }
184
+
185
+ /**
186
+ * A source that answered with something that is not media is a format
187
+ * problem, not a reachability one: it was reached, and a retry cannot change
188
+ * what it sends. Classing it transient would spend an automatic attempt on a
189
+ * body that can never become audio.
190
+ */
191
+ @Test
192
+ fun aNonMediaResponseClassifiesAsAFormatFailureAndIsFatal() {
193
+ val invalidContentType = PlaybackException(
194
+ "msg", null,
195
+ PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE
196
+ )
197
+ assertEquals(
198
+ PlaybackErrorCode.FILE_FORMAT_NOT_RECOGNIZED,
199
+ PlaybackErrorMapping.classify(invalidContentType)
200
+ )
201
+ assertFalse(
202
+ PlaybackErrorMapping.isTransient(PlaybackErrorCode.FILE_FORMAT_NOT_RECOGNIZED)
203
+ )
204
+ }
205
+
206
+ /** A source that is genuinely missing stays a reachability failure. */
207
+ @Test
208
+ fun aMissingFileStillClassifiesAsUnreachable() {
209
+ val notFound = PlaybackException(
210
+ "msg", null,
211
+ PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND
212
+ )
213
+ assertEquals(
214
+ PlaybackErrorCode.SOURCE_UNREACHABLE,
215
+ PlaybackErrorMapping.classify(notFound)
216
+ )
141
217
  }
142
218
 
143
219
  // --- PlaybackErrorMapping.isTransient classification
@@ -230,7 +306,7 @@ class TrackPlayerEventsTest {
230
306
  // the public effects — a new configure + getStateInternal
231
307
  // should return NONE.
232
308
  player.configureInternal(
233
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
309
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, voiceVocabularyDonationEnabled = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
234
310
  )
235
311
  assertEquals(PlayerState.NONE, player.getStateInternal())
236
312
  }
@@ -246,8 +322,8 @@ class TrackPlayerEventsTest {
246
322
  // - listener must NOT clobber the logical -1 from the Kotlin
247
323
  // side with the ExoPlayer-derived 0
248
324
  player.tracks = listOf(
249
- TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
250
- TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null)
325
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null, null, null, null, null),
326
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null, null, null, null, null)
251
327
  )
252
328
  player.currentTrackIndex = -1
253
329
 
@@ -267,7 +343,7 @@ class TrackPlayerEventsTest {
267
343
  // before issuing player.seekTo — the subsequent REASON_SEEK
268
344
  // listener fire must not reset it from the player.
269
345
  player.tracks = (1..5).map {
270
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
346
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
271
347
  }
272
348
  player.currentTrackIndex = 3
273
349
 
@@ -281,7 +357,7 @@ class TrackPlayerEventsTest {
281
357
  @Test
282
358
  fun `handleMediaItemTransition REASON_SEEK consumes the pending track change reason`() {
283
359
  player.tracks = (1..5).map {
284
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
360
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
285
361
  }
286
362
  player.currentTrackIndex = 2
287
363
 
@@ -305,7 +381,7 @@ class TrackPlayerEventsTest {
305
381
  @Test
306
382
  fun `handleMediaItemTransition REASON_SEEK without stashed reason falls back to SEEK`() {
307
383
  player.tracks = (1..5).map {
308
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
384
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
309
385
  }
310
386
  player.currentTrackIndex = 2
311
387
 
@@ -321,11 +397,11 @@ class TrackPlayerEventsTest {
321
397
 
322
398
  @Test
323
399
  fun `handleMediaItemTransition fires callback with current track snapshot`() {
324
- val t2 = TrackItem("t2", "https://example.com/t2.m4a", null, null, null, null, null, null)
400
+ val t2 = TrackItem("t2", "https://example.com/t2.m4a", null, null, null, null, null, null, null, null, null, null)
325
401
  player.tracks = listOf(
326
- TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null),
402
+ TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null, null, null, null, null),
327
403
  t2,
328
- TrackItem("t3", "https://example.com/t3.m4a", null, null, null, null, null, null)
404
+ TrackItem("t3", "https://example.com/t3.m4a", null, null, null, null, null, null, null, null, null, null)
329
405
  )
330
406
  player.currentTrackIndex = 1
331
407
 
@@ -353,8 +429,8 @@ class TrackPlayerEventsTest {
353
429
  @Test
354
430
  fun `handleMediaItemTransition PLAYLIST_CHANGED reports null transitionGapMs`() {
355
431
  player.tracks = listOf(
356
- TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null),
357
- TrackItem("t2", "https://example.com/t2.m4a", null, null, null, null, null, null)
432
+ TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null, null, null, null, null),
433
+ TrackItem("t2", "https://example.com/t2.m4a", null, null, null, null, null, null, null, null, null, null)
358
434
  )
359
435
  player.currentTrackIndex = 0
360
436
 
@@ -376,7 +452,7 @@ class TrackPlayerEventsTest {
376
452
  @Test
377
453
  fun `handleMediaItemTransition SEEK reports null transitionGapMs`() {
378
454
  player.tracks = (1..3).map {
379
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
455
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
380
456
  }
381
457
  player.currentTrackIndex = 1
382
458
 
@@ -395,7 +471,7 @@ class TrackPlayerEventsTest {
395
471
  @Test
396
472
  fun `maybeEmitQueueEnd does nothing on non-ENDED states`() {
397
473
  player.tracks = listOf(
398
- TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null)
474
+ TrackItem("t1", "https://example.com/t1.m4a", null, null, null, null, null, null, null, null, null, null)
399
475
  )
400
476
  var queueEndFires = 0
401
477
  player.onQueueEnd { queueEndFires += 1 }
@@ -425,7 +501,7 @@ class TrackPlayerEventsTest {
425
501
  @Test
426
502
  fun `maybeEmitQueueEnd fires queueEnd and ENDED state on STATE_ENDED with tracks`() {
427
503
  player.tracks = (1..3).map {
428
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
504
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
429
505
  }
430
506
 
431
507
  var queueEndFires = 0
@@ -450,7 +526,7 @@ class TrackPlayerEventsTest {
450
526
  fun `emitStateIfChanged defaults pending reason to SYSTEM on terminal state`() {
451
527
  player.setQueueInternal(
452
528
  (1..1).map {
453
- TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null)
529
+ TrackItem("t$it", "https://example.com/t$it.m4a", null, null, null, null, null, null, null, null, null, null)
454
530
  }
455
531
  )
456
532
  // Manually reset dedup so the next call emits
@@ -479,7 +555,7 @@ class TrackPlayerEventsTest {
479
555
  serviceHandle.destroy()
480
556
  serviceHandle = bindPlaybackServiceForTest(player, context())
481
557
  player.configureInternal(
482
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
558
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, voiceVocabularyDonationEnabled = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
483
559
  )
484
560
  // No listener is bound to the new ExoPlayer's actual STATE_ENDED
485
561
  // path in a Robolectric test, but the callback slot itself is
@@ -493,4 +569,12 @@ class TrackPlayerEventsTest {
493
569
  unsubscribe()
494
570
  assertFalse(fired)
495
571
  }
572
+
573
+ private fun networkFailure() = androidx.media3.common.PlaybackException(
574
+ "boom", null,
575
+ androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)
576
+
577
+ private fun decodingFailure() = androidx.media3.common.PlaybackException(
578
+ "bad frame", null,
579
+ androidx.media3.common.PlaybackException.ERROR_CODE_DECODING_FAILED)
496
580
  }
@@ -4,6 +4,7 @@ import androidx.media3.common.util.UnstableApi
4
4
  import androidx.test.core.app.ApplicationProvider
5
5
  import org.junit.After
6
6
  import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
7
8
  import org.junit.Assert.assertNotEquals
8
9
  import org.junit.Assert.assertNotNull
9
10
  import org.junit.Assert.assertNull
@@ -58,18 +59,293 @@ class TrackPlayerLifecycleTest {
58
59
  serviceHandle.destroy()
59
60
  }
60
61
 
62
+ @Test
63
+ fun `a failure on a track that is not playing is reported, not swallowed`() {
64
+ seedThreeTrackQueue()
65
+
66
+ var reported = 0
67
+ val dispose = player.onError { reported += 1 }
68
+ val states = mutableListOf<PlayerState>()
69
+ val disposeState = player.onStateChange { state, _ -> states.add(state) }
70
+ // Index 2 is not the current track — the shape a standby preroll failure
71
+ // arrives in. The engine argument is not read by the handler.
72
+ player.onError(
73
+ serviceHandle.engine,
74
+ androidx.media3.common.PlaybackException(
75
+ "boom", null,
76
+ androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED),
77
+ 2)
78
+ dispose()
79
+ disposeState()
80
+
81
+ assertEquals("the failure is surfaced rather than swallowed", 1, reported)
82
+ assertFalse(
83
+ "the state is the current track's, which is still playing",
84
+ states.contains(PlayerState.ERROR))
85
+ }
86
+
87
+ /**
88
+ * A repeat of the same failure while the attempt is pending stays inside the
89
+ * library.
90
+ */
91
+ @Test
92
+ fun `an echo of the pending failure is not reported`() {
93
+ seedThreeTrackQueue()
94
+
95
+ var reported = 0
96
+ val dispose = player.onError { reported += 1 }
97
+ player.onError(serviceHandle.engine, networkFailure(), 0)
98
+ player.onError(serviceHandle.engine, networkFailure(), 0)
99
+ dispose()
100
+
101
+ assertEquals("the pending attempt owns both fires", 0, reported)
102
+ }
103
+
104
+ /** A failure after the attempt has run is the consumer's to see. */
105
+ @Test
106
+ fun `a failure after the attempt is reported`() {
107
+ seedThreeTrackQueue()
108
+
109
+ var reported = 0
110
+ val dispose = player.onError { reported += 1 }
111
+ player.onError(serviceHandle.engine, networkFailure(), 0)
112
+ player.retryFailedItem(0)
113
+ player.onError(serviceHandle.engine, networkFailure(), 0)
114
+ dispose()
115
+
116
+ assertEquals("the attempt was spent and the track is still broken", 1, reported)
117
+ }
118
+
119
+ /**
120
+ * Inserting ahead of a track moves it, and its retry budget moves with it.
121
+ * A budget of two with one attempt spent is the one value that tells the
122
+ * moved track apart from both a fresh entry (two left) and a dropped one
123
+ * (none left) at its new index.
124
+ */
125
+ @Test
126
+ fun `a track keeps its retry budget when a track is inserted before it`() {
127
+ seedThreeTrackQueue(autoRetries = 2.0)
128
+ player.onError(serviceHandle.engine, networkFailure(), 0)
129
+ player.retryFailedItem(0)
130
+
131
+ player.addToQueueInternal(listOf(track("https://example.com/new.mp3")), insertBefore = 0)
132
+ assertEquals("the playing track moved to index 1", 1, player.currentTrackIndex)
133
+
134
+ var reported = 0
135
+ val dispose = player.onError { reported += 1 }
136
+ player.onError(serviceHandle.engine, networkFailure(), 1)
137
+ assertEquals(
138
+ "one attempt is left at the new index, so the failure is retried silently; "
139
+ + "a dropped budget would have reported it",
140
+ 0, reported)
141
+ player.retryFailedItem(1)
142
+ player.onError(serviceHandle.engine, networkFailure(), 1)
143
+ dispose()
144
+
145
+ assertEquals(
146
+ "the second attempt was spent, so this failure is reported; "
147
+ + "a fresh budget of two would still be silent",
148
+ 1, reported)
149
+ }
150
+
151
+ /**
152
+ * A user's retry withdraws the automatic attempt scheduled for the same
153
+ * track: left posted, it would fire after the backoff and rebuild the track
154
+ * a second time.
155
+ */
156
+ @Test
157
+ fun `a retry withdraws the scheduled automatic attempt`() {
158
+ player.configureInternal(config(retryBackoffMs = 500.0), context())
159
+ player.setQueueInternal((0 until 3).map { track("https://example.com/$it.mp3") })
160
+ player.currentTrackIndex = 0
161
+ player.onError(serviceHandle.engine, networkFailure(), 0)
162
+
163
+ player.retryFailedItem(0)
164
+ val looper = org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper())
165
+ looper.idle()
166
+ // The retry rebuilt the item once (a remove and an add on the playlist);
167
+ // whatever the looper still holds for the backoff must not rebuild again.
168
+ // Only playlist changes count: the playback thread reports its own
169
+ // source refresh after the prepare, on its own schedule.
170
+ var timelineChanges = 0
171
+ val listener = object : androidx.media3.common.Player.Listener {
172
+ override fun onTimelineChanged(timeline: androidx.media3.common.Timeline, reason: Int) {
173
+ if (reason == androidx.media3.common.Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
174
+ timelineChanges += 1
175
+ }
176
+ }
177
+ }
178
+ player.player!!.addListener(listener)
179
+ looper.idleFor(java.time.Duration.ofMillis(1_000))
180
+ player.player!!.removeListener(listener)
181
+
182
+ assertEquals(
183
+ "the automatic attempt fired after the user's retry and rebuilt the track again",
184
+ 0, timelineChanges)
185
+ }
186
+
187
+ /**
188
+ * The track that ends up at a position after a queue change reports its own
189
+ * failure instead of being taken for an echo of the old queue's attempt.
190
+ */
191
+ @Test
192
+ fun `a failure on the replacement queue is not taken for an echo of the old one`() {
193
+ seedThreeTrackQueue()
194
+ player.onError(serviceHandle.engine, networkFailure(), 0)
195
+
196
+ // The replacement queue gets no automatic attempt, so a failure on it can
197
+ // only be suppressed by a marker left over from the queue before it.
198
+ player.config = config(autoRetries = 0.0)
199
+ player.setQueueInternal((0 until 2).map {
200
+ track("https://example.com/next-$it.mp3")
201
+ })
202
+ player.currentTrackIndex = 0
203
+
204
+ var reported = 0
205
+ val dispose = player.onError { reported += 1 }
206
+ player.onError(serviceHandle.engine, networkFailure(), 0)
207
+ dispose()
208
+
209
+ assertEquals("the attempt belonged to a queue that is gone", 1, reported)
210
+ }
211
+
212
+ private fun config(
213
+ httpHeaders: Map<String, String>? = null,
214
+ userAgent: String? = null,
215
+ autoRetries: Double? = null,
216
+ retryBackoffMs: Double? = null,
217
+ ) = PlayerConfig(
218
+ httpHeaders = httpHeaders, userAgent = userAgent, autoRetries = autoRetries,
219
+ retryBackoffMs = retryBackoffMs, networkTimeoutMs = null, audioContentType = null,
220
+ audioCategoryOptions = null, voiceVocabularyDonationEnabled = null,
221
+ visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null,
222
+ lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null,
223
+ backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null,
224
+ skipToPreviousBehavior = null)
225
+
226
+ private fun track(url: String) = TrackItem(
227
+ id = null, url = url, title = null, artist = null, album = null, duration = null,
228
+ artworkUrl = null, extras = null, replayGainTrackGain = null, replayGainTrackPeak = null,
229
+ replayGainAlbumGain = null, replayGainAlbumPeak = null)
230
+
231
+ private fun networkFailure() = androidx.media3.common.PlaybackException(
232
+ "boom", null,
233
+ androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)
234
+
235
+ /**
236
+ * Under crossfade the standby leg prerolls the next track, so a broken one
237
+ * fails while the leading track is still playing and is surfaced there. The
238
+ * engine then seats it and it fails again as the current track — the listener
239
+ * asked for one thing and must be told once.
240
+ */
241
+ @Test
242
+ fun `a track reported while it was prerolling does not report again when it is seated`() {
243
+ seedThreeTrackQueue(autoRetries = 0.0)
244
+
245
+ var reported = 0
246
+ val dispose = player.onError { reported += 1 }
247
+
248
+ // The standby leg prerolling track 1 fails while track 0 is playing.
249
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
250
+ assertEquals("the listener is told their next track is broken", 1, reported)
251
+
252
+ // The fade begins, so track 1 is the one being heard, and it fails again as
253
+ // the current track.
254
+ player.onCrossfadeBegin(serviceHandle.engine, 1)
255
+ assertEquals("the fade seats the track that was prerolling", 1, player.currentTrackIndex)
256
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
257
+ dispose()
258
+
259
+ assertEquals("and not told a second time about the same track", 1, reported)
260
+ }
261
+
262
+ /**
263
+ * A shuffle renumbers every position, so a record against the pre-shuffle
264
+ * current index names a different track afterwards. Nothing is carried.
265
+ */
266
+ @Test
267
+ fun `a shuffle leaves no track marked as stopped`() {
268
+ seedThreeTrackQueue(autoRetries = 0.0)
269
+ player.onError(serviceHandle.engine, fatalFailure(), 0)
270
+
271
+ player.shuffleQueueInternal()
272
+
273
+ assertEquals("a shuffle seats position 0", 0, player.currentTrackIndex)
274
+ // A stop standing against the current track silences every other track's
275
+ // failure, so a report for one of them is what shows the set is empty.
276
+ var reported = 0
277
+ val dispose = player.onError { reported += 1 }
278
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
279
+ dispose()
280
+
281
+ assertEquals("no track is holding the queue after a shuffle", 1, reported)
282
+ }
283
+
284
+ /**
285
+ * `-1` means "no track", so a failure that carries no index — an engine that
286
+ * reports without one, or a queue that has been cleared — must not enter the
287
+ * set. A member equal to `currentTrackIndex` is what "stopped" means, and at
288
+ * `-1` that would read as stopped and silence every later failure.
289
+ */
290
+ @Test
291
+ fun `a failure with no track does not mark the queue as stopped`() {
292
+ player.configureInternal(config(autoRetries = 0.0), context())
293
+ player.setQueueInternal(emptyList())
294
+ assertEquals("an empty queue seats no track", -1, player.currentTrackIndex)
295
+
296
+ // An engine that reports without an index — AirPlay does — while the queue
297
+ // is empty. There is no position to record.
298
+ player.onError(serviceHandle.engine, fatalFailure())
299
+
300
+ // The add empties the set and restores the current track's entry, so with
301
+ // -1 a member that restore is what carries it forward. It leaves the index
302
+ // at -1 by design, which is what keeps it a member.
303
+ player.addToQueueInternal(
304
+ (0 until 2).map { track("https://example.com/$it.mp3") }, insertBefore = 0)
305
+ assertEquals("an add into an empty queue seats nothing", -1, player.currentTrackIndex)
306
+
307
+ var reported = 0
308
+ val dispose = player.onError { reported += 1 }
309
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
310
+ dispose()
311
+
312
+ assertEquals(
313
+ "a track's failure still reaches the listener — nothing is holding the queue",
314
+ 1, reported)
315
+ }
316
+
317
+ private fun fatalFailure() = androidx.media3.common.PlaybackException(
318
+ "boom", null,
319
+ androidx.media3.common.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE)
320
+
321
+ private fun seedThreeTrackQueue(autoRetries: Double? = null) {
322
+ player.configureInternal(config(autoRetries = autoRetries), context())
323
+ player.setQueueInternal((0 until 3).map { track("https://example.com/$it.mp3") })
324
+ player.currentTrackIndex = 0
325
+ }
326
+
327
+ @Test
328
+ fun `the automatic retry budget is one attempt by default`() {
329
+ assertEquals(1, player.effectiveAutoRetries())
330
+ }
331
+
332
+ @Test
333
+ fun `the consumer can ask for none or more`() {
334
+ player.config = config(autoRetries = 0.0)
335
+ assertEquals(0, player.effectiveAutoRetries())
336
+ player.config = config(autoRetries = 4.0)
337
+ assertEquals(4, player.effectiveAutoRetries())
338
+ player.config = config(autoRetries = 99.0)
339
+ assertEquals(5, player.effectiveAutoRetries())
340
+ player.config = config()
341
+ assertEquals(1, player.effectiveAutoRetries())
342
+ }
343
+
61
344
  @Test
62
345
  fun `configureInternal builds an ExoPlayer and stores config`() {
63
346
  assertNull("player not yet built before configure", player.player)
64
347
 
65
- val config = PlayerConfig(
66
- httpHeaders = mapOf("X-Foo" to "bar"),
67
- userAgent = "rnqp-test/0.0",
68
- autoRetries = null,
69
- retryBackoffMs = null,
70
- networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
71
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
72
- )
348
+ val config = config(httpHeaders = mapOf("X-Foo" to "bar"), userAgent = "rnqp-test/0.0")
73
349
  player.configureInternal(config, context())
74
350
 
75
351
  assertNotNull("configure must build ExoPlayer", player.player)
@@ -87,12 +363,12 @@ class TrackPlayerLifecycleTest {
87
363
  fun `configure destroys then rebuilds — second call yields a fresh ExoPlayer`() {
88
364
  // Universal `configure()` auto-destroy contract: every call to
89
365
  // the public configure tears down any prior state before
90
- // reinitialising. The internal `configureInternal` seam is
366
+ // reinitialising. The internal `configureInternal` entry point is
91
367
  // purely constructive (assumes `player == null` on entry); the
92
368
  // public path owns the destroy step. This test drives the
93
369
  // destroy + reconstruct shape directly to mirror what
94
370
  // `configure()` does end-to-end.
95
- val empty = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
371
+ val empty = config()
96
372
 
97
373
  player.configureInternal(empty, context())
98
374
  val firstInstance = player.player
@@ -122,16 +398,8 @@ class TrackPlayerLifecycleTest {
122
398
 
123
399
  @Test
124
400
  fun `configureInternal stores the latest config on repeat call`() {
125
- val first = PlayerConfig(
126
- httpHeaders = mapOf("A" to "1"), userAgent = "ua-1",
127
- autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
128
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
129
- )
130
- val second = PlayerConfig(
131
- httpHeaders = mapOf("B" to "2"), userAgent = "ua-2",
132
- autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
133
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
134
- )
401
+ val first = config(httpHeaders = mapOf("A" to "1"), userAgent = "ua-1")
402
+ val second = config(httpHeaders = mapOf("B" to "2"), userAgent = "ua-2")
135
403
 
136
404
  player.configureInternal(first, context())
137
405
  player.configureInternal(second, context())
@@ -141,11 +409,7 @@ class TrackPlayerLifecycleTest {
141
409
 
142
410
  @Test
143
411
  fun `destroyInternal releases ExoPlayer + cache, persists user config`() {
144
- val configured = PlayerConfig(
145
- httpHeaders = mapOf("h" to "v"), userAgent = "ua",
146
- autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
147
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
148
- )
412
+ val configured = config(httpHeaders = mapOf("h" to "v"), userAgent = "ua")
149
413
  player.configureInternal(configured, context())
150
414
  // Non-default playback state the consumer set post-configure.
151
415
  player.repeatModeState = RepeatMode.QUEUE
@@ -180,7 +444,7 @@ class TrackPlayerLifecycleTest {
180
444
  @Test
181
445
  fun `destroyInternal is safe to call twice`() {
182
446
  player.configureInternal(
183
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
447
+ config(), context()
184
448
  )
185
449
  player.destroyInternal()
186
450
  player.destroyInternal()
@@ -189,7 +453,7 @@ class TrackPlayerLifecycleTest {
189
453
 
190
454
  @Test
191
455
  fun `configureInternal after destroyInternal rebuilds the ExoPlayer`() {
192
- val cfg = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
456
+ val cfg = config()
193
457
 
194
458
  player.configureInternal(cfg, context())
195
459
  val first = player.player