react-native-queue-player 1.1.2 → 2.0.0

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 (367) hide show
  1. package/QueuePlayer.podspec +14 -9
  2. package/README.md +3 -3
  3. package/android/build.gradle +7 -0
  4. package/android/consumer-rules.pro +72 -3
  5. package/android/src/main/cpp/airplay2_control.c +4 -4
  6. package/android/src/main/cpp/airplay2_control.h +4 -4
  7. package/android/src/main/cpp/airplay2_jni.cpp +26 -22
  8. package/android/src/main/cpp/airplay2_pair.c +7 -7
  9. package/android/src/main/cpp/airplay2_pair.h +2 -2
  10. package/android/src/main/cpp/airplay2_rtsp.c +8 -8
  11. package/android/src/main/cpp/airplay2_session.c +2 -2
  12. package/android/src/main/cpp/airplay_jni.cpp +20 -72
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +7 -16
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +2 -14
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/CellularTransportMonitor.kt +77 -0
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +187 -52
  18. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +27 -7
  19. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +33 -12
  20. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +31 -8
  21. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +45 -30
  22. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +111 -15
  23. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +60 -33
  24. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +1 -1
  25. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +3 -1
  26. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +103 -29
  27. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +77 -29
  28. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +6 -6
  29. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +48 -1
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +3 -2
  31. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +71 -76
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +3 -3
  33. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +2 -2
  34. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +68 -46
  35. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +6 -5
  36. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +5 -6
  37. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +11 -8
  38. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +26 -6
  39. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +22 -3
  40. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +38 -12
  41. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +17 -13
  42. package/android/src/main/java/com/margelo/nitro/queueplayer/SuppliedReplayGain.kt +66 -0
  43. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +870 -278
  44. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +4 -8
  45. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +4 -3
  46. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  47. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +17 -3
  48. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  49. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  50. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +0 -2
  51. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +23 -12
  52. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +16 -9
  53. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  54. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +6 -14
  55. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  56. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +1 -13
  57. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  58. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +1 -1
  59. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +3 -3
  60. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  61. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +11 -35
  62. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +0 -5
  63. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +0 -6
  64. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +32 -33
  65. package/android/src/test/java/androidx/media3/session/{MediaSessionControllerRequestTestSeam.kt → MediaSessionControllerRequestTestSupport.kt} +1 -1
  66. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +12 -0
  67. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineFocusLossTest.kt +115 -0
  68. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +45 -0
  69. package/android/src/test/java/com/margelo/nitro/queueplayer/EngineEndSignalOrderTest.kt +70 -0
  70. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +23 -12
  71. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +0 -6
  72. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +43 -15
  73. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +15 -0
  74. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +135 -4
  75. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +1 -0
  76. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +36 -2
  77. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +24 -18
  78. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterCancelTest.kt +85 -0
  79. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +112 -1
  80. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +5 -3
  81. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +45 -0
  82. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  83. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +47 -0
  84. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +53 -29
  85. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +8 -8
  86. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +33 -1
  87. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +128 -5
  88. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +209 -14
  89. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +150 -10
  90. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainMergeTest.kt +258 -0
  91. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +8 -4
  92. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +2 -2
  93. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowDynamicsProcessingRejectingEnable.kt +19 -0
  94. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  95. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizerRejectingBandWrites.kt +27 -0
  96. package/android/src/test/java/com/margelo/nitro/queueplayer/SuppliedReplayGainTest.kt +61 -0
  97. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +4 -4
  98. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  99. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +163 -0
  100. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +123 -0
  101. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +88 -38
  102. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +205 -27
  103. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +277 -38
  104. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +5 -3
  105. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +2 -1
  106. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +85 -3
  107. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +11 -7
  108. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +2 -1
  109. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +90 -4
  110. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +8 -26
  111. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +3 -3
  112. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +0 -4
  113. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  114. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  115. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitorTest.kt +173 -0
  116. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/net/ShadowConnectivityManagerRecordingRequests.kt +27 -0
  117. package/app.plugin.js +17 -1
  118. package/ios/AVPlayerItemQueueItemId.swift +26 -12
  119. package/ios/AVQueueBuilder.swift +120 -85
  120. package/ios/ArtworkLoader.swift +0 -7
  121. package/ios/ArtworkResolver.swift +22 -4
  122. package/ios/AssetReplayGainReader.swift +118 -0
  123. package/ios/AudioSession.swift +67 -16
  124. package/ios/AudioTapProvider.swift +284 -149
  125. package/ios/CarPlayBridge.swift +0 -12
  126. package/ios/CarPlayCoordinator.swift +18 -2
  127. package/ios/CarPlaySceneDelegate.swift +2 -2
  128. package/ios/Cast/AirPlayRouteState.swift +9 -8
  129. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  130. package/ios/Cast/Core/CastEventBridge.swift +66 -26
  131. package/ios/Cast/Core/CastNowPlayingController.swift +38 -16
  132. package/ios/Cast/Core/CastSession.swift +11 -0
  133. package/ios/Cast/Core/CastTransportRouter.swift +21 -9
  134. package/ios/Cast/Core/LocalMediaServer.swift +41 -52
  135. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +32 -11
  136. package/ios/Cast/Core/MediaServerHandle.swift +0 -4
  137. package/ios/Cast/Core/MediaTokenRegistry.swift +0 -6
  138. package/ios/CastManager.swift +1 -1
  139. package/ios/CrossfadeEngine.swift +660 -226
  140. package/ios/EQTap.swift +20 -17
  141. package/ios/Equalizer.swift +1 -1
  142. package/ios/FLACStreamInfo.swift +65 -0
  143. package/ios/GaplessEngine.swift +57 -30
  144. package/ios/InputGuards.swift +35 -5
  145. package/ios/LookaheadCache.swift +202 -58
  146. package/ios/LookaheadCachePrefetcher.swift +48 -36
  147. package/ios/MediaServer/MediaHTTPConnection.swift +691 -0
  148. package/ios/NetworkRecoveryPolicy.swift +32 -0
  149. package/ios/NowPlayingFormatExtractor.swift +25 -18
  150. package/ios/NowPlayingInfo.swift +84 -48
  151. package/ios/OutputRouteMonitor.swift +16 -3
  152. package/ios/PendingBrowseRequests.swift +1 -1
  153. package/ios/PlaceholderArtwork.swift +9 -5
  154. package/ios/PlaybackEngine.swift +165 -76
  155. package/ios/PlaybackErrorMapping.swift +3 -3
  156. package/ios/PlaybackModeStateMachine.swift +2 -2
  157. package/ios/PlaybackNetworkMonitor.swift +73 -0
  158. package/ios/PlayerStateDerivation.swift +6 -6
  159. package/ios/QueueMutationArithmetic.swift +25 -0
  160. package/ios/QueueSkipArithmetic.swift +5 -3
  161. package/ios/QueueWindowArithmetic.swift +152 -0
  162. package/ios/ReadThroughServer.swift +283 -0
  163. package/ios/RemoteCommands.swift +44 -4
  164. package/ios/ReplayGainData.swift +22 -3
  165. package/ios/ReplayGainExtractor.swift +70 -27
  166. package/ios/Siri/VoiceDonation.swift +14 -9
  167. package/ios/StreamingBitrateProbe.swift +22 -68
  168. package/ios/SuppliedReplayGain.swift +83 -0
  169. package/ios/Tests/AVQueueBuilderTests.swift +158 -221
  170. package/ios/Tests/ActiveItemEngineStub.swift +44 -0
  171. package/ios/Tests/AssetReplayGainReaderTests.swift +228 -0
  172. package/ios/Tests/AudioTapProviderDispatchTargetTests.swift +158 -0
  173. package/ios/Tests/AudioTapProviderReplayGainTests.swift +122 -10
  174. package/ios/Tests/BitrateReresolveBudgetTests.swift +71 -0
  175. package/ios/Tests/CastMediaItemTranslationTests.swift +72 -0
  176. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  177. package/ios/Tests/CrossfadeAdvanceWithoutFadeTests.swift +120 -0
  178. package/ios/Tests/CrossfadeEngineIncomingMixTests.swift +122 -0
  179. package/ios/Tests/CrossfadeEngineTests.swift +164 -0
  180. package/ios/Tests/CrossfadeMixReapplyTests.swift +66 -0
  181. package/ios/Tests/CrossfadePlaybackIntentTests.swift +81 -0
  182. package/ios/Tests/EngineSwapTests.swift +91 -0
  183. package/ios/Tests/EqualizerAudioMixProviderTests.swift +46 -0
  184. package/ios/Tests/EqualizerHybridTests.swift +5 -2
  185. package/ios/Tests/FLACStreamInfoTests.swift +88 -0
  186. package/ios/Tests/GaplessEngineLifecycleTests.swift +4 -4
  187. package/ios/Tests/InputGuardsTests.swift +41 -0
  188. package/ios/Tests/InterruptionIntentTests.swift +77 -0
  189. package/ios/Tests/LookaheadCacheCellularAccessTests.swift +185 -0
  190. package/ios/Tests/LookaheadCachePrefetcherTests.swift +53 -30
  191. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +160 -1
  192. package/ios/Tests/LookaheadCacheTests.swift +44 -18
  193. package/ios/Tests/MediaHTTPConnectionTests.swift +79 -0
  194. package/ios/Tests/MutationDeferralTests.swift +220 -0
  195. package/ios/Tests/NowPlayingInfoTests.swift +86 -99
  196. package/ios/Tests/NowPlayingSnapshotTests.swift +127 -0
  197. package/ios/Tests/OriginRestartStitcherTests.swift +60 -0
  198. package/ios/Tests/PlaybackNetworkMonitorTests.swift +95 -0
  199. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  200. package/ios/Tests/PlayerFixtures.swift +48 -0
  201. package/ios/Tests/PlayerStateDerivationTests.swift +10 -10
  202. package/ios/Tests/QueueMutationGenerationTests.swift +141 -0
  203. package/ios/Tests/QueueRebuildPrefixTests.swift +317 -0
  204. package/ios/Tests/QueueStateTests.swift +3 -1
  205. package/ios/Tests/QueueWindowArithmeticTests.swift +56 -0
  206. package/ios/Tests/QueueWindowSliceTests.swift +162 -0
  207. package/ios/Tests/ReadThroughRoutingLifecycleTests.swift +63 -0
  208. package/ios/Tests/ReadThroughServerTests.swift +345 -0
  209. package/ios/Tests/RemoteCommandsTests.swift +28 -0
  210. package/ios/Tests/ReplayGainExtractorTests.swift +216 -6
  211. package/ios/Tests/ReplayGainMergeTests.swift +230 -0
  212. package/ios/Tests/RetryRecoveryTests.swift +303 -0
  213. package/ios/Tests/SkipCapabilityTests.swift +233 -8
  214. package/ios/Tests/SkipIndexTests.swift +32 -2
  215. package/ios/Tests/SleepTimerPauseIntentTests.swift +43 -0
  216. package/ios/Tests/StallRecoveryTests.swift +97 -0
  217. package/ios/Tests/StreamingBitrateProbeTests.swift +17 -20
  218. package/ios/Tests/TopUpWindowGateTests.swift +394 -0
  219. package/ios/Tests/TrackPlayer+TestHops.swift +14 -0
  220. package/ios/Tests/TrackPlayerCallOrderTests.swift +93 -0
  221. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  222. package/ios/Tests/TrackPlayerConfigureTeardownTests.swift +86 -0
  223. package/ios/Tests/TrackPlayerEndVerdictTests.swift +165 -0
  224. package/ios/Tests/TrackPlayerSeekTests.swift +137 -0
  225. package/ios/Tests/TrackPlayerThreadingTests.swift +127 -0
  226. package/ios/Tests/TrackSourceClassifierTests.swift +33 -7
  227. package/ios/Tests/VoiceVocabularyOptInTests.swift +38 -0
  228. package/ios/TrackPlayer+Automotive.swift +101 -0
  229. package/ios/TrackPlayer+Cache.swift +232 -0
  230. package/ios/TrackPlayer+Config.swift +291 -0
  231. package/ios/TrackPlayer+EngineDelegate.swift +209 -0
  232. package/ios/TrackPlayer+EventsAPI.swift +111 -0
  233. package/ios/TrackPlayer+EventsDispatch.swift +968 -0
  234. package/ios/TrackPlayer+EventsWiring.swift +173 -0
  235. package/ios/TrackPlayer+Lifecycle.swift +930 -0
  236. package/ios/TrackPlayer+NowPlayingFormat.swift +258 -0
  237. package/ios/TrackPlayer+Queue.swift +940 -0
  238. package/ios/TrackPlayer+Recovery.swift +196 -0
  239. package/ios/TrackPlayer+Skip.swift +455 -0
  240. package/ios/TrackPlayer+SleepTimer.swift +175 -0
  241. package/ios/TrackPlayer+State.swift +108 -0
  242. package/ios/TrackPlayer+Threading.swift +135 -0
  243. package/ios/TrackPlayer+Transport.swift +267 -0
  244. package/ios/TrackPlayer+Window.swift +195 -0
  245. package/ios/TrackPlayer.swift +305 -4610
  246. package/ios/Visualizer.swift +6 -5
  247. package/ios/tests-harness/Podfile +1 -1
  248. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +19 -11
  249. package/ios/tests-harness/scripts/seed-xcodeproj.rb +2 -2
  250. package/lib/module/hooks/useActiveTrack.js +29 -22
  251. package/lib/module/hooks/useActiveTrack.js.map +1 -1
  252. package/lib/module/hooks/useCast.js +8 -26
  253. package/lib/module/hooks/useCast.js.map +1 -1
  254. package/lib/module/hooks/useEqualizer.js +19 -12
  255. package/lib/module/hooks/useEqualizer.js.map +1 -1
  256. package/lib/module/hooks/useLookaheadCache.js +3 -7
  257. package/lib/module/hooks/useLookaheadCache.js.map +1 -1
  258. package/lib/module/hooks/useQueue.js +66 -19
  259. package/lib/module/hooks/useQueue.js.map +1 -1
  260. package/lib/module/index.js +6 -3
  261. package/lib/module/index.js.map +1 -1
  262. package/lib/module/queueDelta.js +41 -0
  263. package/lib/module/queueDelta.js.map +1 -0
  264. package/lib/module/types.js +28 -5
  265. package/lib/module/types.js.map +1 -1
  266. package/lib/typescript/TrackPlayer.nitro.d.ts +53 -28
  267. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  268. package/lib/typescript/hooks/useActiveTrack.d.ts +5 -7
  269. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -1
  270. package/lib/typescript/hooks/useCast.d.ts +6 -1
  271. package/lib/typescript/hooks/useCast.d.ts.map +1 -1
  272. package/lib/typescript/hooks/useEqualizer.d.ts +2 -1
  273. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -1
  274. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -1
  275. package/lib/typescript/hooks/useQueue.d.ts +4 -4
  276. package/lib/typescript/hooks/useQueue.d.ts.map +1 -1
  277. package/lib/typescript/index.d.ts +2 -1
  278. package/lib/typescript/index.d.ts.map +1 -1
  279. package/lib/typescript/queueDelta.d.ts +10 -0
  280. package/lib/typescript/queueDelta.d.ts.map +1 -0
  281. package/lib/typescript/types.d.ts +168 -12
  282. package/lib/typescript/types.d.ts.map +1 -1
  283. package/nitrogen/generated/android/c++/JFunc_void_QueueChangeDelta_double_QueueChangeReason.hpp +85 -0
  284. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +45 -19
  285. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +3 -3
  286. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +8 -4
  287. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  288. package/nitrogen/generated/android/c++/JQueueChangeDelta.hpp +128 -0
  289. package/nitrogen/generated/android/c++/JTrackItem.hpp +19 -3
  290. 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
  291. 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
  292. 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
  293. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +0 -2
  294. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +0 -2
  295. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +0 -2
  296. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +0 -2
  297. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +0 -2
  298. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +0 -2
  299. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +0 -2
  300. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +0 -2
  301. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +0 -2
  302. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +0 -2
  303. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +0 -2
  304. 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
  305. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +0 -2
  306. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +0 -2
  307. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +0 -2
  308. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +0 -2
  309. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +0 -2
  310. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +0 -2
  311. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +0 -2
  312. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +0 -2
  313. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +0 -2
  314. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +0 -2
  315. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +0 -2
  316. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +2 -0
  317. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +2 -0
  318. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +6 -4
  319. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +2 -0
  320. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +9 -4
  321. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  322. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeDelta.kt +86 -0
  323. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +24 -4
  324. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -2
  325. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +16 -16
  326. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +80 -65
  327. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  328. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +6 -3
  329. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +5 -5
  330. package/nitrogen/generated/ios/swift/Func_void_QueueChangeDelta_double_QueueChangeReason.swift +46 -0
  331. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +3 -3
  332. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +32 -24
  333. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +20 -2
  334. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  335. package/nitrogen/generated/ios/swift/QueueChangeDelta.swift +82 -0
  336. package/nitrogen/generated/ios/swift/TrackItem.swift +73 -1
  337. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +6 -3
  338. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +7 -3
  339. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  340. package/nitrogen/generated/shared/c++/QueueChangeDelta.hpp +113 -0
  341. package/nitrogen/generated/shared/c++/TrackItem.hpp +18 -2
  342. package/package.json +8 -8
  343. package/src/TrackPlayer.nitro.ts +53 -27
  344. package/src/hooks/useActiveTrack.ts +29 -22
  345. package/src/hooks/useCast.ts +14 -15
  346. package/src/hooks/useEqualizer.ts +19 -12
  347. package/src/hooks/useLookaheadCache.ts +3 -7
  348. package/src/hooks/useQueue.ts +66 -17
  349. package/src/index.ts +13 -3
  350. package/src/queueDelta.ts +41 -0
  351. package/src/types.ts +169 -12
  352. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +0 -82
  353. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
  354. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +0 -15
  355. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +0 -192
  356. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +0 -135
  357. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +0 -27
  358. package/ios/Cast/Core/LocalAddressMonitor.swift +0 -110
  359. package/ios/Cast/Core/MediaHTTPConnection.swift +0 -349
  360. package/ios/MetadataReader.swift +0 -555
  361. package/ios/Tests/CrossfadeEngineStubTests.swift +0 -43
  362. package/ios/Tests/MetadataReaderTests.swift +0 -603
  363. package/ios/Tests/PlaybackEngineProtocolTests.swift +0 -92
  364. package/ios/Tests/VoiceDonationTests.swift +0 -26
  365. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +0 -101
  366. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +0 -46
  367. /package/ios/{Cast/Core → MediaServer}/MimeTypes.swift +0 -0
@@ -0,0 +1,930 @@
1
+ import AVFoundation
2
+ import NitroModules
3
+
4
+ extension TrackPlayer {
5
+ // MARK: - Lifecycle
6
+
7
+ func configure(config: PlayerConfig) throws -> Promise<Void> {
8
+ // `[self]` explicit: this body holds `self` strongly for its run, and the
9
+ // long-lived callbacks it installs below capture `[weak self]`; the
10
+ // explicit outer capture is what tells the compiler the mix is intended.
11
+ return enqueue { [self] in
12
+ // Universal auto-destroy contract: every `configure()` call
13
+ // tears down any prior engine, audio session, observers, and
14
+ // pending pipeline state before reinitialising. On the first
15
+ // configure of a session the gate inside `internalDestroy`
16
+ // short-circuits when `self.engine == nil`, so only the timer +
17
+ // sleep-timer teardown runs; on subsequent calls it is a real
18
+ // teardown. Effect: configure is always a clean-slate operation;
19
+ // consumers don't need to remember to call `destroy()` before
20
+ // re-configuring.
21
+ // Capture the prior eviction policy before overwriting config — a change
22
+ // clears the rebuilt cache below so it starts fresh under the new policy
23
+ // (infrequent; a cache's eviction ordering is construction-fixed).
24
+ let previousEvictionPolicy = self.config.lookaheadCacheEvictionPolicy ?? .lru
25
+ self.internalDestroy()
26
+ self.config = config
27
+ let evictionPolicyChanged =
28
+ previousEvictionPolicy != (config.lookaheadCacheEvictionPolicy ?? .lru)
29
+ // Seed the progress-emission throttle from config and start observing
30
+ // app background/foreground. `internalDestroy()` above removed any
31
+ // prior observers, so this re-registers cleanly on every configure.
32
+ self.applyProgressEmissionIntervals()
33
+ self.installAppLifecycleObservers()
34
+ // Pin-at-configure visualization-disabled flag. Mirrored to
35
+ // the AudioTapProvider singleton so `Visualizer.subscribe`
36
+ // and the Visualizer Consumer's `wantsTap` predicate can
37
+ // read it live. Default `true` preserves existing behaviour.
38
+ AudioTapProvider.shared.setVisualizationEnabled(
39
+ config.visualizationEnabled ?? true
40
+ )
41
+ // Taken from the first call and never re-read: the Siri entitlement this
42
+ // stands for is fixed when the app is signed, so a later value could not
43
+ // mean anything.
44
+ self.pinVoiceVocabularyDonation(
45
+ config.voiceVocabularyDonationEnabled ?? false
46
+ )
47
+ // The queue mirror is only maintained while a car is attached, so
48
+ // rebuild it the moment one attaches.
49
+ CarPlayCoordinator.shared.onCarAttached = { [weak self] in
50
+ guard let self else { return }
51
+ // The car scene attaches on main, and the mirror is built from the
52
+ // track list — so it is built on the queue that owns it. Fires from
53
+ // `setCarConnected(true)`, so the car is attached by construction.
54
+ self.playerQueue.async {
55
+ self.syncCarPlayQueueMirror(
56
+ self.buildCarPlayQueueSnapshot(), carAttached: true)
57
+ }
58
+ }
59
+ let engine = GaplessEngine()
60
+ self.engine = engine
61
+ // configure() always rebuilds the default GaplessEngine, so the mode
62
+ // state must reset to match. Otherwise getPlaybackMode() keeps
63
+ // reporting a stale crossfade selection after a clean-slate
64
+ // reconfigure while the engine is actually gapless — and a subsequent
65
+ // setPlaybackMode(.crossfade) would no-op against that stale state and
66
+ // never rebuild the CrossfadeEngine.
67
+ self.playbackModeState = PlaybackModeStateMachine.initial
68
+ let player = engine.player
69
+ self.player = player
70
+ self.gaplessFlipArmed = true
71
+ self.installGaplessObservers(on: player)
72
+ self.installEventObservers(on: player)
73
+ // Re-apply persisted config to the fresh engine. These survive a
74
+ // destroy→configure cycle and may be set before the first
75
+ // configure, so the first engine must pick them up — matching the
76
+ // engine-swap path. Speed is seeded (not set) so it doesn't start
77
+ // the paused player via AVPlayer.rate; play() applies it on the
78
+ // first resume.
79
+ engine.setRepeatMode(self.repeatModeState)
80
+ engine.setVolume(self.volumeState)
81
+ engine.seedPlaybackSpeed(self.playbackSpeedState)
82
+ engine.setPitchCorrectionMode(self.pitchCorrectionModeState)
83
+ self.wireAudioSession()
84
+ // Start observing receiver-driven cast session state; idempotent
85
+ // on re-configure (no-op if already started).
86
+ self.castEventBridge.start()
87
+ // Activate the lockscreen mirror for cast sessions. Subscribes
88
+ // through `PlaybackStateRouter.shared` — fires on every
89
+ // session-active transition.
90
+ CastNowPlayingController.shared.start()
91
+ // The provider assigns `AVPlayerItem.audioMix` and walks the player's
92
+ // items to do it, so its engine-facing work belongs on the queue that
93
+ // serialises player access. Set before wiring, so the wire itself
94
+ // lands there. The provider is a process-wide singleton, hence the
95
+ // weak capture — teardown puts the target back on main.
96
+ AudioTapProvider.shared.setMixDispatchTarget(
97
+ self.playerQueue,
98
+ isCurrent: { [weak self] in self?.isOnPlayerQueue ?? false })
99
+ // Wire the shared audio-tap provider onto the engine. The
100
+ // provider attaches an MTAudioProcessingTap-backed
101
+ // AVAudioMix to each queued AVPlayerItem when any
102
+ // registered consumer (EQ, visualizer) wants the tap.
103
+ AudioTapProvider.shared.wireToPlaybackEngine(engine)
104
+ // Notify subscribers when a tap-side RG extract lands so any
105
+ // `onNowPlayingFormatChange` listener picks up the new RG
106
+ // values immediately. `getNowPlayingFormat()` reads RG live
107
+ // from `AudioTapProvider`, so the synchronous read path
108
+ // doesn't depend on this callback — it's purely for change-
109
+ // notification subscribers.
110
+ AudioTapProvider.shared.onItemReplayGainDataPopulated = { [weak self] item in
111
+ guard let self else { return }
112
+ self.emitCurrentNowPlayingFormat(for: item)
113
+ }
114
+ // Category only. Claiming the session is deferred to the first play, so
115
+ // merely loading the module does not stop whatever else the user has
116
+ // playing — a consumer app calls configure() at launch, long before
117
+ // anyone presses play. The category still has to be set here: route
118
+ // sharing and the interruption observers below depend on it.
119
+ self.audioSession.configureCategory(
120
+ mode: AudioCategoryMapping.mode(for: config.audioContentType),
121
+ options: AudioCategoryMapping.options(
122
+ for: config.audioCategoryOptions,
123
+ contentType: config.audioContentType
124
+ )
125
+ )
126
+ self.audioSession.installObservers()
127
+ let networkMonitor = PlaybackNetworkMonitor(queue: self.playerQueue)
128
+ networkMonitor.onNetworkRestored = { [weak self] in self?.handleNetworkRestored() }
129
+ networkMonitor.start()
130
+ self.networkMonitor = networkMonitor
131
+ self.startNowPlayingPeriodicTimer()
132
+ self.wireRemoteCommands()
133
+ self.remoteCommands.install()
134
+ self.remoteCommands.setSkipCapability(
135
+ canSkipNext: self.cachedSkipCapability.canSkipNext,
136
+ canSkipPrevious: self.cachedSkipCapability.canSkipPrevious
137
+ )
138
+ // Route CarPlay Up Next row taps through the same steps the
139
+ // `skipToIndex` Hybrid runs — engine guard, index validation,
140
+ // and cast routing (a tap during an active cast session jumps
141
+ // the RECEIVER; the local engine stays paused, so a local index
142
+ // write would desync from it) — then the local index write.
143
+ // Invoked on main by the scene delegate's Up Next row handler; the
144
+ // index validation and the write both touch player state, so the body
145
+ // runs on the queue that owns it.
146
+ CarPlayCoordinator.shared.skipToIndexHandler = { [weak self] index in
147
+ guard let self = self else { return }
148
+ self.playerQueue.async {
149
+ guard self.engine != nil else { return }
150
+ guard let target = InputGuards.validQueueIndex(
151
+ Double(index), count: self.tracks.count
152
+ ) else { return }
153
+ if CastTransportRouter.routeSkipToIndex(index: target) { return }
154
+ self.applyNewCurrentIndex(newIndex: target, reason: .userSkipToIndex)
155
+ }
156
+ }
157
+ // Refresh on every configure() so a header / UA update lands
158
+ // on the next artwork resolve. Live config — read fresh on
159
+ // each track-change resolve.
160
+ self.artworkResolver.configHeaders = config.httpHeaders
161
+ self.artworkResolver.configUserAgent = config.userAgent
162
+ // Swap the cover-art placeholder to the consumer-supplied image
163
+ // (or back to the built-in when unset). Every placeholder site
164
+ // reads `PlaceholderArtwork.image`, so this is the only hook needed.
165
+ PlaceholderArtwork.setCustom(uri: config.placeholderArtworkUri)
166
+ // Same live config for the cast lockscreen's own artwork
167
+ // resolver so authenticated covers resolve during cast.
168
+ CastNowPlayingController.shared.configureArtwork(
169
+ headers: config.httpHeaders, userAgent: config.userAgent
170
+ )
171
+ // Build the lookahead cache + prefetcher honouring the live
172
+ // `lookaheadConfig`. When disabled, neither is built.
173
+ // `internalDestroy` above already nilled out any prior cache
174
+ // + prefetcher, so this is always a fresh construction.
175
+ if self.lookaheadConfig.enabled {
176
+ let cache = LookaheadCache(
177
+ maxSizeBytes: LookaheadCache.maxBytes(
178
+ forMb: self.configuredCacheMaxSizeMb()
179
+ ),
180
+ evictionPolicy: self.configuredEvictionPolicy(),
181
+ allowsCellularAccess: self.lookaheadConfig.allowsCellularAccess ?? true
182
+ )
183
+ // A policy change wipes the retained files so the cache refills
184
+ // under the new eviction order.
185
+ if evictionPolicyChanged {
186
+ cache.clear()
187
+ }
188
+ self.lookaheadCache = cache
189
+ let prefetcher = LookaheadCachePrefetcher(
190
+ cache: cache,
191
+ confinedTo: self.playerQueue,
192
+ configHeaders: config.httpHeaders,
193
+ configUserAgent: config.userAgent
194
+ )
195
+ prefetcher.defaultLookaheadCount =
196
+ InputGuards.validLookaheadCount(self.lookaheadConfig.lookaheadCount)
197
+ prefetcher.onTrackProcessed = { [weak self] in self?.emitCacheStatus() }
198
+ self.lookaheadCachePrefetcher = prefetcher
199
+ self.startReadThroughServer(for: cache)
200
+ } else if evictionPolicyChanged {
201
+ // Cache disabled at configure — no instance to clear(); purge the
202
+ // on-disk files directly so a policy change still starts fresh
203
+ // (parity with Android's unconditional clear).
204
+ LookaheadCache.purgeDefaultDirectory()
205
+ }
206
+ }
207
+ }
208
+
209
+ /// Install AudioSession interruption + route-change callbacks on
210
+ /// top of the transport. Centralised here so both callback types
211
+ /// share the same emit-state-then-call-native-method pattern.
212
+ private func wireAudioSession() {
213
+ self.audioSession.onInterruption = { [weak self] kind in
214
+ guard let self else { return }
215
+ // Delivered on main; everything below reads or writes player state.
216
+ self.playerQueue.async {
217
+ switch kind {
218
+ case .began:
219
+ // System wants us paused (phone call, Siri, alarm). Pause
220
+ // + stash `.interruption` for the resulting `timeControl
221
+ // Status` KVO to pick up on the terminal `.paused` emit.
222
+ //
223
+ // Through the engine, mirroring `.endedShouldResume`: CrossfadeEngine
224
+ // has no `self.player`, and its `pause()` also cancels an in-flight
225
+ // fade and its arming observer. Pausing the raw player would leave
226
+ // the fade's wall-clock completion task live to swap legs mid-call.
227
+ self.pendingStateChangeReason = .interruption
228
+ self.isInterrupted = true
229
+ // The system holds the session now; the resume below reactivates
230
+ // it only if this is cleared.
231
+ self.audioSession.noteInterrupted()
232
+ self.engine?.pause()
233
+ case .endedShouldResume:
234
+ // System says it's safe to resume. Resume through the engine so
235
+ // the user's playback speed is restored (a raw AVPlayer.play()
236
+ // resets rate to 1.0) and crossfade — whose legs the OS paused on
237
+ // `.began` but which has no `self.player` to act on — resumes too.
238
+ // play() transitions via `.buffering` → `.playing`; buffering
239
+ // passes through as `.system` so the reason sticks until
240
+ // `.playing` lands.
241
+ self.pendingStateChangeReason = .interruption
242
+ self.isInterrupted = false
243
+ // Some interruptions deactivate our session — a call that took the
244
+ // route, notably. Resuming into a session we never reactivated plays
245
+ // nothing, so reactivate before the transport call.
246
+ self.audioSession.activate(
247
+ mode: AudioCategoryMapping.mode(for: self.config.audioContentType),
248
+ options: AudioCategoryMapping.options(
249
+ for: self.config.audioCategoryOptions,
250
+ contentType: self.config.audioContentType
251
+ ))
252
+ self.beginPlayback()
253
+ case .endedShouldNotResume:
254
+ // Interruption ended but user resolved it in a way that
255
+ // shouldn't restart music. The player is already paused
256
+ // (OS pre-paused on `.began`, we stayed paused), so no
257
+ // transport call and no state transition to report.
258
+ // Apple's docs confirm clients should not resume — no
259
+ // client action needed; a dedicated `onInterruptionEnd`
260
+ // event could land later if consumers need to clear UI
261
+ // annotations.
262
+ //
263
+ // Intent is cleared too: the system has told us the user resolved
264
+ // this in a way that should not restart music, so a later window
265
+ // refill or retry must not treat the pre-interruption intent as
266
+ // still standing.
267
+ self.isInterrupted = false
268
+ self.wantsToPlay = false
269
+ break
270
+ }
271
+
272
+ // An interruption is a normal lifecycle signal (call / Siri / a route
273
+ // handoff), NOT a playback error — surfacing it on the `onError` stream
274
+ // makes consumers render an error banner for a routine pause. The pause
275
+ // is already reported via the `.paused` state stamped with
276
+ // `reason = .interruption`, which is the correct, non-error signal.
277
+ }
278
+ }
279
+
280
+ self.audioSession.onRouteChange = { [weak self] kind in
281
+ guard let self else { return }
282
+ // Delivered on main; everything below reads or writes player state.
283
+ self.playerQueue.async {
284
+ switch kind {
285
+ case .oldDeviceUnavailable:
286
+ // Headphones unplugged, BT disconnected — never blast
287
+ // music over the speaker per Apple HIG. Pause through the engine:
288
+ // `self.player` is nil under CrossfadeEngine, so reading it here
289
+ // would let the route change pass unhandled on that engine and do
290
+ // the one thing this case exists to prevent.
291
+ self.pendingStateChangeReason = .routeChange
292
+ // Drop the transport intent too. This pause is final — the HIG says a
293
+ // vanished route must not resume on its own — and the resume paths
294
+ // (drained refill, retry) read the intent, so leaving it set would let
295
+ // one of them start audio out of the speaker afterwards. An
296
+ // interruption is deliberately different: it keeps the intent, because
297
+ // `.endedShouldResume` is meant to resume.
298
+ self.wantsToPlay = false
299
+ self.engine?.pause()
300
+ case .newDeviceAvailable:
301
+ // New route arrived (BT connected, headphones plugged in).
302
+ // No automatic transport action; state is unchanged, so
303
+ // no emission either. Consumers wanting to observe new
304
+ // devices can subscribe to AVAudioSession notifications
305
+ // directly if needed.
306
+ break
307
+ }
308
+ }
309
+ }
310
+
311
+ // Audio-session lifecycle failures (`setCategory` /
312
+ // `setActive(true)` / `setActive(false)`) surface as non-fatal
313
+ // PlaybackErrors with stable codes (see `AudioSession.onError`
314
+ // doc). Deliberately do NOT call `emitState(.error, ...)` —
315
+ // these are session-level failures, not player-level; the
316
+ // player itself isn't in an error state and may still recover
317
+ // (e.g. another app released the session).
318
+ self.audioSession.onError = { [weak self] sessionError in
319
+ guard let self else { return }
320
+ // Audio-session errors don't have a meaningful AVPlayer-level
321
+ // standardized code (they're session-scope, not playback-
322
+ // scope). Surface as `.unknown` with the SCREAMING_SNAKE_CASE
323
+ // session code in `nativeDomain` so consumers can branch on
324
+ // `nativeDomain.startsWith("AUDIO_SESSION_")` if they need
325
+ // session-specific UX.
326
+ // Apply the same `stripQuery` PII safeguard as the rest of
327
+ // the error fire sites — audio-session messages today don't
328
+ // carry URLs, but defense in depth.
329
+ let safeMessage = PlaybackErrorMapping.stripQuery(sessionError.message)
330
+ // Audio-session errors are session-scope; no specific queue
331
+ // item to correlate, so queueItemId + url are empty.
332
+ let err = PlaybackError(
333
+ code: .unknown,
334
+ message: safeMessage,
335
+ fatal: false,
336
+ nativeCode: Double(sessionError.nativeCode),
337
+ nativeDomain: sessionError.nativeDomain,
338
+ nativeMessage: safeMessage,
339
+ queueItemId: "",
340
+ url: ""
341
+ )
342
+ self.errorListeners.forEach { $0(err) }
343
+ }
344
+ }
345
+
346
+ /// Map `MPRemoteCommandCenter` events to the public transport
347
+ /// methods. The closure stamps `pendingStateChangeReason = .system`
348
+ /// before invoking the transport call so the conditional
349
+ /// `pendingStateChangeReason ?? .user` write inside each transport
350
+ /// method short-circuits and the resulting state-change emit
351
+ /// carries `.system`.
352
+ ///
353
+ /// Race window: the stamp and the transport call it belongs to are two
354
+ /// separate blocks on `playerQueue`, so an unrelated block landing between
355
+ /// them (e.g. an `.interruption` stamp from `wireAudioSession`) can
356
+ /// overwrite the slot. The practical impact
357
+ /// is bounded — the default reason in `emitStateChangeIfChanged` is
358
+ /// `.system`, so a stolen `.system` re-falls-through to `.system` on
359
+ /// the next consumer. An `.interruption` interleave tags the
360
+ /// state-change as `.interruption` (semantically correct — the
361
+ /// interruption IS why the state changed); the subsequent play()
362
+ /// emit defaults to `.user`. Acceptable degradation.
363
+ ///
364
+ /// Togglers: `.togglePlayPause` flips off ANY actively-progressing
365
+ /// state, not just `.playing` — `.buffering` (waiting-to-play)
366
+ /// counts as "user is trying to play" and should pause on tap.
367
+ ///
368
+ /// `[weak self]` prevents the cycle TrackPlayer → remoteCommands →
369
+ /// onCommand → TrackPlayer (TrackPlayer owns RemoteCommands as a
370
+ /// `let` property).
371
+ private func wireRemoteCommands() {
372
+ self.remoteCommands.onCommand = { [weak self] command in
373
+ guard let self = self else { return }
374
+ // MPRemoteCommandCenter calls its handlers on main; everything below
375
+ // reads or writes player state, starting with the staked reason.
376
+ self.playerQueue.async {
377
+ self.pendingStateChangeReason = .system
378
+ switch command {
379
+ case .play:
380
+ _ = try? self.play()
381
+ case .pause:
382
+ _ = try? self.pause()
383
+ case .togglePlayPause:
384
+ let active =
385
+ self.lastReportedState == .playing ||
386
+ self.lastReportedState == .buffering
387
+ if active {
388
+ _ = try? self.pause()
389
+ } else {
390
+ _ = try? self.play()
391
+ }
392
+ case .stop:
393
+ _ = try? self.stop()
394
+ case .nextTrack:
395
+ _ = try? self.skipToNext()
396
+ case .previousTrack:
397
+ _ = try? self.skipToPrevious()
398
+ case .skipForward(let seconds):
399
+ // Chromecast: relative-seek the receiver from ITS position. AirPlay /
400
+ // local: `routeSeekBy` returns false (no GCK session), so seek the
401
+ // local engine — whose position is accurate (AVPlayer plays to the
402
+ // AirPlay route). `seekTo` clamps to [0, duration].
403
+ if !CastTransportRouter.routeSeekBy(deltaMs: Int64(seconds * 1000)) {
404
+ let base = self.engine?.currentPositionSeconds ?? 0
405
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) + seconds)
406
+ }
407
+ case .skipBackward(let seconds):
408
+ if !CastTransportRouter.routeSeekBy(deltaMs: -Int64(seconds * 1000)) {
409
+ let base = self.engine?.currentPositionSeconds ?? 0
410
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) - seconds)
411
+ }
412
+ case .changePlaybackPosition(let seconds):
413
+ _ = try? self.seekTo(position: seconds)
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ /// Build the `NowPlayingInfo` snapshot every lock-screen refresh is pushed
420
+ /// with: the current track, and the engine's position, duration and
421
+ /// effective rate.
422
+ ///
423
+ /// Every player-side value comes from the engine, and under crossfade the
424
+ /// engine's accessors follow the leg that is audible — so position, duration
425
+ /// and rate describe one leg rather than a mix of two. A `nil` track + zero values when
426
+ /// the player is gone is observed as "queue empty" by
427
+ /// `NowPlayingInfo.refreshAll`, which then clears the dictionary.
428
+ internal func nowPlayingSnapshot() -> NowPlayingInfo.Snapshot {
429
+ let idx = self.currentTrackIndex
430
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
431
+ ? self.tracks[idx] : nil
432
+ // The engine owns these, not `self.player`: the gapless queue player does
433
+ // not exist under crossfade.
434
+ let elapsed = self.engine?.currentPositionSeconds ?? 0
435
+ let duration = self.engine?.currentDurationSeconds ?? 0
436
+ // `timeControlStatus`, not `isPlaying`: under crossfade the former follows
437
+ // the audible leg exactly as position and duration do, while the latter
438
+ // reads the leading leg alone. Taking `isPlaying` here would report rate 0
439
+ // whenever the outgoing item has ended but the fade has not yet completed,
440
+ // pairing a live position with a paused rate — and it is the same source
441
+ // `PlayerStateDerivation` uses, so the pushed rate and the emitted state
442
+ // cannot disagree.
443
+ //
444
+ // MediaPlayer wants the effective rate: the configured speed while
445
+ // playing, zero otherwise. Publishing a requested-but-not-yet-audible rate
446
+ // makes the system extrapolate the scrubber through a stall.
447
+ let rate = (self.engine?.timeControlStatus == .playing) ? self.playbackSpeedState : 0
448
+ return NowPlayingInfo.Snapshot(
449
+ track: track,
450
+ elapsedSeconds: elapsed,
451
+ durationSeconds: duration,
452
+ rate: rate
453
+ )
454
+ }
455
+
456
+ /// Drive the 10s lock-screen tick. The snapshot is built here, on the
457
+ /// side that owns player state, and pushed into `NowPlayingInfo`.
458
+ /// A paused player short-circuits — elapsed is unchanged and rate is
459
+ /// already 0, so the write would be pure noise.
460
+ private func startNowPlayingPeriodicTimer() {
461
+ self.nowPlayingInfo.startPeriodicTimer { [weak self] in
462
+ guard let self else { return }
463
+ // The tick fires on main, where the Now Playing surface lives, but the
464
+ // snapshot is player state — so it is built on the queue that owns that
465
+ // state and handed back as a value.
466
+ self.playerQueue.async {
467
+ let snapshot = self.nowPlayingSnapshot()
468
+ guard snapshot.rate != 0 else { return }
469
+ self.nowPlayingInfo.refreshPositionAndRate(snapshot)
470
+ }
471
+ }
472
+ }
473
+
474
+ func destroy() throws -> Promise<Void> {
475
+ return enqueue {
476
+ self.internalDestroy()
477
+ }
478
+ }
479
+
480
+ /// Synchronous teardown. Idempotent — cancels the timer sources, then
481
+ /// no-ops when nothing is configured. Called from public `destroy()`
482
+ /// and from `configure()` (which auto-destroys before reinitialising;
483
+ /// see `configure(_:)` for the universal contract).
484
+ ///
485
+ /// Persists `config` / `repeatModeState` / `lookaheadConfig` across
486
+ /// the teardown so a consumer who called `setRepeatMode()` /
487
+ /// `setLookaheadCache()` doesn't lose those settings on a
488
+ /// reconfigure cycle. Matches Android's symmetric behaviour.
489
+ private func internalDestroy() {
490
+ // Ahead of the gate below: `setSleepTimer` / `setSleepTimerToTrackEnd`
491
+ // arm without an engine, so a gated teardown would strand a live 2 Hz
492
+ // tick source with nothing left to cancel it. All three are idempotent
493
+ // and none read `engine`.
494
+ self.stopProgressFallbackTimer()
495
+ self.stopSleepTimerTick()
496
+ self.sleepTimerCore.clear()
497
+ // Idempotent gate: when nothing has been configured, every line
498
+ // below would either no-op (nil-coalesced calls) or perform
499
+ // unnecessary work against fresh state. `self.engine` is the
500
+ // "is configured?" sentinel because it is non-nil under BOTH
501
+ // engines. `self.player` is not — it holds the gapless
502
+ // `AVQueuePlayer` and stays nil for as long as a CrossfadeEngine
503
+ // is active, so gating on it would skip the whole teardown
504
+ // mid-crossfade and leak every resource below.
505
+ //
506
+ // `configure(config:)` assigns `self.engine` before
507
+ // `audioSession.configureCategory`, `audioSession.installObservers`,
508
+ // `startNowPlayingPeriodicTimer` and `remoteCommands.install`, so the
509
+ // gate admits cleanup of every resource those install. The tear-down
510
+ // lines below are individually idempotent.
511
+ // Above the gate: the tap provider is a process-wide singleton, so an
512
+ // instance that configured and is then dropped without `destroy()` would
513
+ // otherwise leave the singleton pointing at a dead player's queue, and
514
+ // every later player's mix work would dispatch onto it.
515
+ AudioTapProvider.shared.detachFromEngine()
516
+ // The provider is process-wide, so a callback left installed on it outlives
517
+ // this player and would run against the one being torn down.
518
+ AudioTapProvider.shared.onItemReplayGainDataPopulated = nil
519
+ AudioTapProvider.shared.resetMixDispatchTarget()
520
+
521
+ guard self.engine != nil else { return }
522
+ // Tear the prefetcher down BEFORE the cache. Cancelling the
523
+ // prefetcher's Task chain arms cooperative cancellation so no
524
+ // new download dispatches against the about-to-die URLSession;
525
+ // the cache's `tearDown` then invalidates the session, which
526
+ // is what actually kills any already-suspended
527
+ // URLSessionDownloadTask mid-flight.
528
+ self.lookaheadCachePrefetcher?.tearDown()
529
+ self.lookaheadCachePrefetcher = nil
530
+ // Before the cache: a connection mid-response holds an open handle on a
531
+ // cache file, and the listener is what owns those connections.
532
+ self.readThroughServer?.stop()
533
+ self.readThroughServer = nil
534
+ self.lookaheadCache?.tearDown()
535
+ self.lookaheadCache = nil
536
+ // Drop the mirror chain rather than cancelling it: a receiver call already
537
+ // on the wire is left to finish, and the next session starts its own chain
538
+ // instead of queueing behind a torn-down one.
539
+ self.castMirrorChain = nil
540
+
541
+ self.tearDownGaplessObservers()
542
+ self.tearDownEventObservers()
543
+ self.removeAppLifecycleObservers()
544
+ self.networkMonitor?.stop()
545
+ self.networkMonitor = nil
546
+ self.audioSession.tearDownObservers()
547
+ self.audioSession.onInterruption = nil
548
+ self.audioSession.onRouteChange = nil
549
+ self.audioSession.onError = nil
550
+ self.nowPlayingInfo.clear()
551
+ self.remoteCommands.uninstall()
552
+ self.remoteCommands.onCommand = nil
553
+ self.artworkResolver.reset()
554
+ // The tap provider is a process-wide singleton: drop its pointer at the
555
+ // engine and put its dispatch target back on main, so the next
556
+ // `configure` starts from the clean slate its contract promises.
557
+ // CarPlay coordinator state lives for process lifetime. The
558
+ // bridge slots survive destroy — the subscription is owned by
559
+ // the JS consumer (re-registering replaces the slot), matching
560
+ // the documented Android behaviour. Browse data, in-flight
561
+ // resolver promises, and the artwork cache reset with the
562
+ // configure cycle.
563
+ let coordinator = CarPlayCoordinator.shared
564
+ coordinator.dataProvider.setSnapshot(nil)
565
+ coordinator.queueProvider.setSnapshot(nil)
566
+ coordinator.skipToIndexHandler = nil
567
+ coordinator.onCarAttached = nil
568
+ coordinator.pending.clear()
569
+ coordinator.loader.clearCache()
570
+ // engine.release() drains the underlying AVQueuePlayer's items +
571
+ // nils its mix provider.
572
+ self.engine?.release()
573
+ self.engine = nil
574
+ self.player = nil
575
+ self.sleepTimerFading = false
576
+ self.queueState.clear()
577
+ // The clear emits nothing, so a mirror built from `onQueueChange` deltas
578
+ // would splice the next delta onto the queue this just dropped. Moving
579
+ // the revision past the next emit's value is what a subscriber reads as
580
+ // a missed event, and re-reads.
581
+ self.queueRevision += 1
582
+ self.currentTrackIndex = -1
583
+ self.currentTrackSource = nil
584
+ self.currentBufferState = .empty
585
+ self.lastEmittedBufferState = nil
586
+ self.lastBufferStateQueueItemId = nil
587
+ self.currentFullyBuffered = false
588
+ self.lastEmittedFullyBuffered = nil
589
+ self.hasStartedPlaying = false
590
+ self.wantsToPlay = false
591
+ self.isInterrupted = false
592
+ self.isRecoveringFromStall = false
593
+ self.engineReconcileDeferred = false
594
+ self.lastEmittedQueueItemId = nil
595
+ self.didPlayToEndPending = false
596
+ // `engine` + `player` are both nil now, so `activeMediaItem` is
597
+ // nil: refresh emits null + cancels in-flight extractor Tasks via
598
+ // the generation token.
599
+ self.refreshNowPlayingFormatForActiveItem()
600
+ // `lastErrorQueueItemId` / `lastErrorCode` reset alongside the
601
+ // player so a subsequent error on a different item still fires
602
+ // (the dedup is per-item).
603
+ self.lastErrorQueueItemId = nil
604
+ self.lastErrorCode = nil
605
+ self.pendingRetryQueueItemId = nil
606
+ self.retryAttemptsRemaining.removeAll()
607
+ self.lastReportedState = .none
608
+ self.reachedQueueEnd = false
609
+ self.pendingTrackChangeReason = nil
610
+ self.pendingStateChangeReason = nil
611
+ self.audioSession.deactivate()
612
+ // Tear down the cast event bridge — disposers unsubscribe from
613
+ // both the global router and any active session's state listener.
614
+ self.castEventBridge.stop()
615
+ // Tear down the cast lockscreen mirror.
616
+ CastNowPlayingController.shared.stop()
617
+ // Final (false, false) emit so any still-registered subscriber
618
+ // sees the player is gone. JS hooks dispose their listener on
619
+ // unmount; this covers stragglers + native-side subscribers.
620
+ self.recomputeCapabilities()
621
+ }
622
+
623
+ /// Attach the KVO chain that powers the stalling-flip gapless
624
+ /// pattern: observe `player.currentItem` for item transitions,
625
+ /// and for each current item observe `status` so we can flip
626
+ /// `automaticallyWaitsToMinimizeStalling` to false the moment
627
+ /// the current item reaches `.readyToPlay` while
628
+ /// `gaplessFlipArmed == true`. Safe to call repeatedly — previous
629
+ /// observers are invalidated first.
630
+ ///
631
+ /// `.new`-only KVO: `.initial` is deliberately NOT used. `.initial`
632
+ /// fires synchronously during observer registration on whatever
633
+ /// thread the install runs from, which can land mid-mutation and
634
+ /// skip the lib's debounce step (`pendingStateChangeReason`
635
+ /// consumption, `lastEmittedQueueItemId` dedup). The explicit
636
+ /// `playerQueue.async` bootstrap below replicates `.initial` semantics
637
+ /// by funnelling through the same handler the `.new` callback
638
+ /// uses, but on a stable post-registration state. See NOTES.md §18.
639
+ internal func installGaplessObservers(on player: AVQueuePlayer) {
640
+ tearDownGaplessObservers()
641
+
642
+ currentItemObserver = player.observe(
643
+ \.currentItem, options: [.new]
644
+ ) { [weak self] _, _ in
645
+ // KVO fires on AVFoundation's internal queue; funnel onto the queue
646
+ // that owns player state, like every other player mutation.
647
+ guard let self else { return }
648
+ self.playerQueue.async {
649
+ self.attachStatusObserverIfArmed()
650
+ self.handleCurrentItemDidChange()
651
+ }
652
+ }
653
+ // Bootstrap to replicate what `.initial` would have delivered. The async
654
+ // hop matches the `.new` callback body's dispatch shape so a fresh-install
655
+ // cur-item is observed identically to a subsequent transition.
656
+ playerQueue.async { [weak self] in
657
+ guard let self else { return }
658
+ self.attachStatusObserverIfArmed()
659
+ self.handleCurrentItemDidChange()
660
+ }
661
+ }
662
+
663
+ private func attachStatusObserverIfArmed() {
664
+ currentItemStatusObserver?.invalidate()
665
+ currentItemStatusObserver = nil
666
+ currentItemDurationObserver?.invalidate()
667
+ currentItemDurationObserver = nil
668
+ tearDownBufferObservers()
669
+ guard let item = self.player?.currentItem else {
670
+ // No current item (empty queue / torn down) — settle to empty.
671
+ recomputeBufferState()
672
+ return
673
+ }
674
+
675
+ // A local file that does not exist is deterministically unplayable, and its
676
+ // `AVURLAsset` load can stall in `.unknown` indefinitely under host load —
677
+ // the item never reaches `.failed`, so the `\.status` observer below would
678
+ // never surface it and the queue sits buffering with no error. Fail it now
679
+ // and install no observer for this dead item: a status observer that did
680
+ // eventually reach `.failed` would re-report the same source under a
681
+ // second, different error code. A retry re-seats
682
+ // the item and re-runs this check, so a source that appears is picked up
683
+ // then. Pausing first halts the AVQueuePlayer chain-advance the same way the
684
+ // `.failed` branch does. Read the asset's own URL, the one the player loads.
685
+ if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
686
+ !FileManager.default.fileExists(atPath: asset.url.path) {
687
+ self.player?.pause()
688
+ let missing = NSError(
689
+ domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist,
690
+ userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."])
691
+ self.dispatchErrorOrRetry(
692
+ item: item, underlying: missing, nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED")
693
+ return
694
+ }
695
+
696
+ // Two distinct concerns share this KVO:
697
+ // 1. `.readyToPlay` flips `automaticallyWaitsToMinimizeStalling`
698
+ // back to false (initial-buffer leniency one-shot).
699
+ // 2. `.failed` surfaces an `onError` event — the only hook that
700
+ // catches initial-load failures (closed port, 4xx/5xx HTTP,
701
+ // malformed asset). Mid-playback failures arrive separately
702
+ // via `AVPlayerItemFailedToPlayToEndTime`.
703
+ //
704
+ // `.new`-only KVO + synchronous bootstrap. The bootstrap is sync
705
+ // (not a `playerQueue.async` hop like the sibling `\.currentItem` /
706
+ // `\.timeControlStatus` bootstraps) because this method is itself
707
+ // called on `playerQueue` (from the `\.currentItem` KVO body's hop)
708
+ // — running the bootstrap inline catches a cache-hit path
709
+ // where the asset resolves to `.readyToPlay` before observer
710
+ // install would otherwise leave `gaplessFlipArmed` set forever.
711
+ //
712
+ // Re-entrancy: a `.failed` immediate resolution dispatches through
713
+ // `handleCurrentItemFailedToLoad` → `dispatchErrorOrRetry`, which
714
+ // may schedule a retry that calls `fullRebuildPlayerQueue`. The
715
+ // rebuild's `tearDownGaplessObservers` invalidates the just-
716
+ // installed observer reference; the synchronous bootstrap's local
717
+ // `let observer` reference still holds but its observation is
718
+ // disconnected. Safe — the inflight bootstrap path completes,
719
+ // returns to `attachStatusObserverIfArmed`, returns to the parent
720
+ // KVO body, and the rebuild's freshly-installed observer takes
721
+ // over.
722
+ currentItemStatusObserver = item.observe(
723
+ \.status, options: [.new]
724
+ ) { [weak self] item, _ in
725
+ guard let self else { return }
726
+ self.playerQueue.async {
727
+ self.dispatchItemStatus(item)
728
+ }
729
+ }
730
+ self.dispatchItemStatus(item)
731
+ currentItemDurationObserver = item.observe(
732
+ \.duration, options: [.new]
733
+ ) { [weak self] _, _ in
734
+ guard let self else { return }
735
+ self.playerQueue.async {
736
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
737
+ }
738
+ }
739
+ installBufferObservers(on: item)
740
+ }
741
+
742
+ /// KVO on the gapless current item's three native playback-buffer flags.
743
+ /// Any change recomputes the `BufferState` and the fully-buffered flag on
744
+ /// `playerQueue`, ahead of the next progress tick.
745
+ private func installBufferObservers(on item: AVPlayerItem) {
746
+ let onChange: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
747
+ self?.playerQueue.async {
748
+ self?.recomputeBufferState()
749
+ self?.recomputeFullyBuffered()
750
+ }
751
+ }
752
+ bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new], changeHandler: onChange)
753
+ bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onChange)
754
+ bufferFullObserver = item.observe(\.isPlaybackBufferFull, options: [.new], changeHandler: onChange)
755
+ recomputeBufferState()
756
+ recomputeFullyBuffered()
757
+ }
758
+
759
+ private func tearDownBufferObservers() {
760
+ bufferEmptyObserver?.invalidate(); bufferEmptyObserver = nil
761
+ bufferKeepUpObserver?.invalidate(); bufferKeepUpObserver = nil
762
+ bufferFullObserver?.invalidate(); bufferFullObserver = nil
763
+ }
764
+
765
+ /// Single source of truth for the per-item `\.status` KVO body.
766
+ /// Called both from the observer callback and from the synchronous
767
+ /// bootstrap in `attachStatusObserverIfArmed`.
768
+ private func dispatchItemStatus(_ item: AVPlayerItem) {
769
+ switch item.status {
770
+ case .readyToPlay:
771
+ if self.gaplessFlipArmed, let player = self.player {
772
+ player.automaticallyWaitsToMinimizeStalling = false
773
+ self.gaplessFlipArmed = false
774
+ }
775
+ // The asset's audio track + format descriptions are reliably
776
+ // loadable by the time we hit `.readyToPlay`. The post-track-
777
+ // change null-emit may race ahead of asset metadata load on
778
+ // first hit, so we re-resolve here to land the real format.
779
+ self.refreshNowPlayingFormatForActiveItem()
780
+ // Streaming-item audioMix catch-up for the gapless engine.
781
+ // engine.setItems / insertItems install the mix at insert
782
+ // time only for items whose `tracks` key is already loaded;
783
+ // streaming items insert with `audioMix == nil` and pick up
784
+ // their mix here once `.readyToPlay` confirms tracks have
785
+ // loaded. The crossfade engine has a parallel catch-up path
786
+ // through its `engineActiveItemFormatChanged` delegate hook —
787
+ // both call into `refreshActiveItemMixes`, which is
788
+ // idempotent on items that already carry a tap, so a double-
789
+ // fire is harmless.
790
+ AudioTapProvider.shared.refreshActiveItemMixes()
791
+ // Duration is only reliably readable once the item is `.readyToPlay`.
792
+ // Re-publish now-playing so the lock-screen scrubber gets a duration on
793
+ // first load + auto-advance (not just after a manual skip, which is the
794
+ // only path that otherwise forces a state change that re-publishes it).
795
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
796
+ case .failed:
797
+ // Pause synchronously BEFORE surfacing the typed error. Without
798
+ // this, AVQueuePlayer treats `.failed` as end-of-item and chain-
799
+ // advances through subsequent items in the queue, walking
800
+ // `currentTrackIndex` past the failure point while the JS error
801
+ // event is still in flight. Pausing halts that walk so the
802
+ // consumer sees the error against the failing track and can
803
+ // decide whether to skip / retry / surface UI.
804
+ self.player?.pause()
805
+ self.handleCurrentItemFailedToLoad(item)
806
+ default:
807
+ break
808
+ }
809
+ }
810
+
811
+ /// Surface a `.failed` AVPlayerItem as an `onError` + state ERROR.
812
+ /// Idempotent per (queueItemId, standardized-code) so AVPlayer's
813
+ /// internal item-rebuild during retries doesn't bypass dedup. A
814
+ /// retry that flips the same item to a DIFFERENT error code still
815
+ /// surfaces because the code is part of the dedup key.
816
+ ///
817
+ /// `nativeDomain` on the emitted error is stamped to
818
+ /// `PLAYER_ITEM_LOAD_FAILED` so JS consumers can distinguish item-
819
+ /// load failures (the raw asset never reached `.readyToPlay`) from
820
+ /// mid-playback failures (which arrive via
821
+ /// `AVPlayerItemFailedToPlayToEndTime` and keep the underlying
822
+ /// `NSError.domain`). The underlying NSError code + message remain
823
+ /// available via `nativeCode` + `nativeMessage`.
824
+ private func handleCurrentItemFailedToLoad(_ item: AVPlayerItem) {
825
+ guard item.status == .failed else { return }
826
+ let underlying = item.error as NSError?
827
+ dispatchErrorOrRetry(
828
+ item: item,
829
+ underlying: underlying,
830
+ nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED"
831
+ )
832
+ }
833
+
834
+ /// Shared dispatch path for `handleCurrentItemFailedToLoad` +
835
+ /// `handlePlayerItemFailedToPlayToEndTime`. Encapsulates: (1)
836
+ /// classifier, (2) qid-fallback to fresh UUID for raw items, (3)
837
+ /// (queueItemId, code) dedup, (4) auto-retry path with dedup
838
+ /// clear-on-retry, (5) terminal `errorListeners.forEach` +
839
+ /// emitState(.error). Single chokepoint so the dedup + retry
840
+ /// contracts can't drift between callers.
841
+ ///
842
+ /// `nativeDomainOverride` lets a caller stamp a stable lib-defined
843
+ /// SCREAMING_SNAKE_CASE marker on the emitted `nativeDomain` (e.g.
844
+ /// `PLAYER_ITEM_LOAD_FAILED` from the `.failed`-status path) so JS
845
+ /// consumers can branch on the cause without parsing raw native
846
+ /// domain strings. Nil preserves the underlying `NSError.domain`.
847
+ internal func dispatchErrorOrRetry(
848
+ item: AVPlayerItem?,
849
+ underlying: NSError?,
850
+ nativeDomainOverride: String? = nil
851
+ ) {
852
+ let mapped = PlaybackErrorMapping.classify(underlying)
853
+ // Fresh UUID fallback per fire (NOT a static sentinel) when
854
+ // queueItemId is missing — two unrelated items both lacking the
855
+ // associated-object would otherwise dedup against each other.
856
+ // Production AVQueueBuilder.makeItem always sets queueItemId; this
857
+ // path covers raw `makeItemRaw` items (test stubs).
858
+ let qid = item?.queueItemId ?? UUID().uuidString
859
+ // An echo of a failure whose attempt has not run yet belongs to that
860
+ // attempt, not to the consumer.
861
+ if let qidReal = item?.queueItemId, self.pendingRetryQueueItemId == qidReal { return }
862
+ if self.lastErrorQueueItemId == qid && self.lastErrorCode == mapped { return }
863
+ self.lastErrorQueueItemId = qid
864
+ self.lastErrorCode = mapped
865
+ // Auto-retry path: transient errors with retries left schedule
866
+ // a delayed rebuild. Skip JS emit + state.error transition until
867
+ // retries exhausted.
868
+ if PlaybackErrorMapping.isTransient(mapped),
869
+ let qidReal = item?.queueItemId,
870
+ (self.retryAttemptsRemaining[qidReal] ?? 0) > 0 {
871
+ self.retryAttemptsRemaining[qidReal] = self.retryAttemptsRemaining[qidReal]! - 1
872
+ self.pendingRetryQueueItemId = qidReal
873
+ // Clear dedup so the retry-then-fail (if any) fires fresh.
874
+ self.lastErrorQueueItemId = nil
875
+ self.lastErrorCode = nil
876
+ let backoff = self.effectiveRetryBackoffMs()
877
+ playerQueue.asyncAfter(deadline: .now() + .milliseconds(backoff)) {
878
+ [weak self] in self?.retryFailedItem(queueItemId: qidReal)
879
+ }
880
+ return
881
+ }
882
+ // Surface the failing item's queueItemId + URL so consumers can
883
+ // correlate the error to the specific track even when
884
+ // AVQueuePlayer chain-advances past it before the JS event fires.
885
+ // Reuse `qid` (which is the lib-generated UUID fallback for raw
886
+ // items) so the JS-visible field matches the dedup key.
887
+ let failedQid = item?.queueItemId ?? qid
888
+ // The track's own URL. A routed item's asset URL names the read-through
889
+ // server, which tells a consumer nothing about which track failed — and
890
+ // correlating the error to a track is the whole purpose of this field.
891
+ let failedUrl = item?.sourceURL ?? (item?.asset as? AVURLAsset)?.url.absoluteString ?? ""
892
+ let err = self.buildPlaybackError(
893
+ underlying,
894
+ mapped: mapped,
895
+ queueItemId: failedQid,
896
+ url: failedUrl,
897
+ nativeDomainOverride: nativeDomainOverride
898
+ )
899
+ self.errorListeners.forEach { $0(err) }
900
+ self.emitState(.error, reason: .error)
901
+ }
902
+
903
+ internal func tearDownGaplessObservers() {
904
+ currentItemObserver?.invalidate()
905
+ currentItemObserver = nil
906
+ currentItemStatusObserver?.invalidate()
907
+ currentItemStatusObserver = nil
908
+ currentItemDurationObserver?.invalidate()
909
+ currentItemDurationObserver = nil
910
+ tearDownBufferObservers()
911
+ }
912
+
913
+ /// Re-arm the initial-buffer leniency: the next `.readyToPlay`
914
+ /// flips stalling back to false. Called from `fullRebuildPlayerQueue`
915
+ /// (central choke point for "the currentItem is being replaced by
916
+ /// a fresh AVPlayerItem") and from `removeFromQueue` when the
917
+ /// currentItem is dropped and AVQueuePlayer auto-advances to an
918
+ /// item that may not yet be buffered.
919
+ ///
920
+ /// The `\.currentItem` KVO chain already installed in `configure`
921
+ /// handles attaching the status observer once the new currentItem
922
+ /// is in place — we don't attach it synchronously here to avoid
923
+ /// racing against the caller's in-flight `player.insert(...)`
924
+ /// sequence.
925
+ internal func rearmGaplessFlip() {
926
+ guard let player = self.player else { return }
927
+ self.gaplessFlipArmed = true
928
+ player.automaticallyWaitsToMinimizeStalling = true
929
+ }
930
+ }