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
@@ -0,0 +1,982 @@
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
+ // Not when the queue is stopped on a reported failure: the player is
253
+ // parked on a track the consumer was told failed, and `beginPlayback`
254
+ // writes a rate without consulting the transport intent, so resuming
255
+ // here would start it — or the track behind it — unasked.
256
+ if self.reportedFailureOnCurrentTrack == nil {
257
+ self.beginPlayback()
258
+ }
259
+ case .endedShouldNotResume:
260
+ // Interruption ended but user resolved it in a way that
261
+ // shouldn't restart music. The player is already paused
262
+ // (OS pre-paused on `.began`, we stayed paused), so no
263
+ // transport call and no state transition to report.
264
+ // Apple's docs confirm clients should not resume — no
265
+ // client action needed; a dedicated `onInterruptionEnd`
266
+ // event could land later if consumers need to clear UI
267
+ // annotations.
268
+ //
269
+ // Intent is cleared too: the system has told us the user resolved
270
+ // this in a way that should not restart music, so a later window
271
+ // refill or retry must not treat the pre-interruption intent as
272
+ // still standing.
273
+ self.isInterrupted = false
274
+ self.wantsToPlay = false
275
+ break
276
+ }
277
+
278
+ // An interruption is a normal lifecycle signal (call / Siri / a route
279
+ // handoff), NOT a playback error — surfacing it on the `onError` stream
280
+ // makes consumers render an error banner for a routine pause. The pause
281
+ // is already reported via the `.paused` state stamped with
282
+ // `reason = .interruption`, which is the correct, non-error signal.
283
+ }
284
+ }
285
+
286
+ self.audioSession.onRouteChange = { [weak self] kind in
287
+ guard let self else { return }
288
+ // Delivered on main; everything below reads or writes player state.
289
+ self.playerQueue.async {
290
+ switch kind {
291
+ case .oldDeviceUnavailable:
292
+ // Headphones unplugged, BT disconnected — never blast
293
+ // music over the speaker per Apple HIG. Pause through the engine:
294
+ // `self.player` is nil under CrossfadeEngine, so reading it here
295
+ // would let the route change pass unhandled on that engine and do
296
+ // the one thing this case exists to prevent.
297
+ self.pendingStateChangeReason = .routeChange
298
+ // Drop the transport intent too. This pause is final — the HIG says a
299
+ // vanished route must not resume on its own — and the resume paths
300
+ // (drained refill, retry) read the intent, so leaving it set would let
301
+ // one of them start audio out of the speaker afterwards. An
302
+ // interruption is deliberately different: it keeps the intent, because
303
+ // `.endedShouldResume` is meant to resume.
304
+ self.wantsToPlay = false
305
+ self.engine?.pause()
306
+ case .newDeviceAvailable:
307
+ // New route arrived (BT connected, headphones plugged in).
308
+ // No automatic transport action; state is unchanged, so
309
+ // no emission either. Consumers wanting to observe new
310
+ // devices can subscribe to AVAudioSession notifications
311
+ // directly if needed.
312
+ break
313
+ }
314
+ }
315
+ }
316
+
317
+ // Audio-session lifecycle failures (`setCategory` /
318
+ // `setActive(true)` / `setActive(false)`) surface as non-fatal
319
+ // PlaybackErrors with stable codes (see `AudioSession.onError`
320
+ // doc). Deliberately do NOT call `emitState(.error, ...)` —
321
+ // these are session-level failures, not player-level; the
322
+ // player itself isn't in an error state and may still recover
323
+ // (e.g. another app released the session).
324
+ self.audioSession.onError = { [weak self] sessionError in
325
+ guard let self else { return }
326
+ // Audio-session errors don't have a meaningful AVPlayer-level
327
+ // standardized code (they're session-scope, not playback-
328
+ // scope). Surface as `.unknown` with the SCREAMING_SNAKE_CASE
329
+ // session code in `nativeDomain` so consumers can branch on
330
+ // `nativeDomain.startsWith("AUDIO_SESSION_")` if they need
331
+ // session-specific UX.
332
+ // Apply the same `stripQuery` PII safeguard as the rest of
333
+ // the error fire sites — audio-session messages today don't
334
+ // carry URLs, but defense in depth.
335
+ let safeMessage = PlaybackErrorMapping.stripQuery(sessionError.message)
336
+ // Audio-session errors are session-scope; no specific queue
337
+ // item to correlate, so queueItemId + url are empty.
338
+ let err = PlaybackError(
339
+ code: .unknown,
340
+ message: safeMessage,
341
+ fatal: false,
342
+ nativeCode: Double(sessionError.nativeCode),
343
+ nativeDomain: sessionError.nativeDomain,
344
+ nativeMessage: safeMessage,
345
+ queueItemId: "",
346
+ url: ""
347
+ )
348
+ self.errorListeners.forEach { $0(err) }
349
+ }
350
+ }
351
+
352
+ /// Map `MPRemoteCommandCenter` events to the public transport
353
+ /// methods. The closure stamps `pendingStateChangeReason = .system`
354
+ /// before invoking the transport call so the conditional
355
+ /// `pendingStateChangeReason ?? .user` write inside each transport
356
+ /// method short-circuits and the resulting state-change emit
357
+ /// carries `.system`.
358
+ ///
359
+ /// Race window: the stamp and the transport call it belongs to are two
360
+ /// separate blocks on `playerQueue`, so an unrelated block landing between
361
+ /// them (e.g. an `.interruption` stamp from `wireAudioSession`) can
362
+ /// overwrite the slot. The practical impact
363
+ /// is bounded — the default reason in `emitStateChangeIfChanged` is
364
+ /// `.system`, so a stolen `.system` re-falls-through to `.system` on
365
+ /// the next consumer. An `.interruption` interleave tags the
366
+ /// state-change as `.interruption` (semantically correct — the
367
+ /// interruption IS why the state changed); the subsequent play()
368
+ /// emit defaults to `.user`. Acceptable degradation.
369
+ ///
370
+ /// Togglers: `.togglePlayPause` flips off ANY actively-progressing
371
+ /// state, not just `.playing` — `.buffering` (waiting-to-play)
372
+ /// counts as "user is trying to play" and should pause on tap.
373
+ ///
374
+ /// `[weak self]` prevents the cycle TrackPlayer → remoteCommands →
375
+ /// onCommand → TrackPlayer (TrackPlayer owns RemoteCommands as a
376
+ /// `let` property).
377
+ private func wireRemoteCommands() {
378
+ self.remoteCommands.onCommand = { [weak self] command in
379
+ guard let self = self else { return }
380
+ // MPRemoteCommandCenter calls its handlers on main; everything below
381
+ // reads or writes player state, starting with the staked reason.
382
+ self.playerQueue.async {
383
+ self.pendingStateChangeReason = .system
384
+ switch command {
385
+ case .play:
386
+ _ = try? self.play()
387
+ case .pause:
388
+ _ = try? self.pause()
389
+ case .togglePlayPause:
390
+ let active =
391
+ self.lastReportedState == .playing ||
392
+ self.lastReportedState == .buffering
393
+ if active {
394
+ _ = try? self.pause()
395
+ } else {
396
+ _ = try? self.play()
397
+ }
398
+ case .stop:
399
+ _ = try? self.stop()
400
+ case .nextTrack:
401
+ _ = try? self.skipToNext()
402
+ case .previousTrack:
403
+ _ = try? self.skipToPrevious()
404
+ case .skipForward(let seconds):
405
+ // Chromecast: relative-seek the receiver from ITS position. AirPlay /
406
+ // local: `routeSeekBy` returns false (no GCK session), so seek the
407
+ // local engine — whose position is accurate (AVPlayer plays to the
408
+ // AirPlay route). `seekTo` clamps to [0, duration].
409
+ if !CastTransportRouter.routeSeekBy(deltaMs: Int64(seconds * 1000)) {
410
+ let base = self.engine?.currentPositionSeconds ?? 0
411
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) + seconds)
412
+ }
413
+ case .skipBackward(let seconds):
414
+ if !CastTransportRouter.routeSeekBy(deltaMs: -Int64(seconds * 1000)) {
415
+ let base = self.engine?.currentPositionSeconds ?? 0
416
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) - seconds)
417
+ }
418
+ case .changePlaybackPosition(let seconds):
419
+ _ = try? self.seekTo(position: seconds)
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ /// Build the `NowPlayingInfo` snapshot every lock-screen refresh is pushed
426
+ /// with: the current track, and the engine's position, duration and
427
+ /// effective rate.
428
+ ///
429
+ /// Every player-side value comes from the engine, and under crossfade the
430
+ /// engine's accessors follow the leg that is audible — so position, duration
431
+ /// and rate describe one leg rather than a mix of two. A `nil` track + zero values when
432
+ /// the player is gone is observed as "queue empty" by
433
+ /// `NowPlayingInfo.refreshAll`, which then clears the dictionary.
434
+ internal func nowPlayingSnapshot() -> NowPlayingInfo.Snapshot {
435
+ let idx = self.currentTrackIndex
436
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
437
+ ? self.tracks[idx] : nil
438
+ // The engine owns these, not `self.player`: the gapless queue player does
439
+ // not exist under crossfade.
440
+ let elapsed = self.engine?.currentPositionSeconds ?? 0
441
+ let duration = self.engine?.currentDurationSeconds ?? 0
442
+ // `timeControlStatus`, not `isPlaying`: under crossfade the former follows
443
+ // the audible leg exactly as position and duration do, while the latter
444
+ // reads the leading leg alone. Taking `isPlaying` here would report rate 0
445
+ // whenever the outgoing item has ended but the fade has not yet completed,
446
+ // pairing a live position with a paused rate — and it is the same source
447
+ // `PlayerStateDerivation` uses, so the pushed rate and the emitted state
448
+ // cannot disagree.
449
+ //
450
+ // MediaPlayer wants the effective rate: the configured speed while
451
+ // playing, zero otherwise. Publishing a requested-but-not-yet-audible rate
452
+ // makes the system extrapolate the scrubber through a stall.
453
+ let rate = (self.engine?.timeControlStatus == .playing) ? self.playbackSpeedState : 0
454
+ return NowPlayingInfo.Snapshot(
455
+ track: track,
456
+ elapsedSeconds: elapsed,
457
+ durationSeconds: duration,
458
+ rate: rate
459
+ )
460
+ }
461
+
462
+ /// Drive the 10s lock-screen tick. The snapshot is built here, on the
463
+ /// side that owns player state, and pushed into `NowPlayingInfo`.
464
+ /// A paused player short-circuits — elapsed is unchanged and rate is
465
+ /// already 0, so the write would be pure noise.
466
+ private func startNowPlayingPeriodicTimer() {
467
+ self.nowPlayingInfo.startPeriodicTimer { [weak self] in
468
+ guard let self else { return }
469
+ // The tick fires on main, where the Now Playing surface lives, but the
470
+ // snapshot is player state — so it is built on the queue that owns that
471
+ // state and handed back as a value.
472
+ self.playerQueue.async {
473
+ let snapshot = self.nowPlayingSnapshot()
474
+ guard snapshot.rate != 0 else { return }
475
+ self.nowPlayingInfo.refreshPositionAndRate(snapshot)
476
+ }
477
+ }
478
+ }
479
+
480
+ func destroy() throws -> Promise<Void> {
481
+ return enqueue {
482
+ self.internalDestroy()
483
+ }
484
+ }
485
+
486
+ /// Synchronous teardown. Idempotent — cancels the timer sources, then
487
+ /// no-ops when nothing is configured. Called from public `destroy()`
488
+ /// and from `configure()` (which auto-destroys before reinitialising;
489
+ /// see `configure(_:)` for the universal contract).
490
+ ///
491
+ /// Persists `config` / `repeatModeState` / `lookaheadConfig` across
492
+ /// the teardown so a consumer who called `setRepeatMode()` /
493
+ /// `setLookaheadCache()` doesn't lose those settings on a
494
+ /// reconfigure cycle. Matches Android's symmetric behaviour.
495
+ private func internalDestroy() {
496
+ // Ahead of the gate below: `setSleepTimer` / `setSleepTimerToTrackEnd`
497
+ // arm without an engine, so a gated teardown would strand a live 2 Hz
498
+ // tick source with nothing left to cancel it. All three are idempotent
499
+ // and none read `engine`.
500
+ self.stopProgressFallbackTimer()
501
+ self.stopSleepTimerTick()
502
+ self.sleepTimerCore.clear()
503
+ // Idempotent gate: when nothing has been configured, every line
504
+ // below would either no-op (nil-coalesced calls) or perform
505
+ // unnecessary work against fresh state. `self.engine` is the
506
+ // "is configured?" sentinel because it is non-nil under BOTH
507
+ // engines. `self.player` is not — it holds the gapless
508
+ // `AVQueuePlayer` and stays nil for as long as a CrossfadeEngine
509
+ // is active, so gating on it would skip the whole teardown
510
+ // mid-crossfade and leak every resource below.
511
+ //
512
+ // `configure(config:)` assigns `self.engine` before
513
+ // `audioSession.configureCategory`, `audioSession.installObservers`,
514
+ // `startNowPlayingPeriodicTimer` and `remoteCommands.install`, so the
515
+ // gate admits cleanup of every resource those install. The tear-down
516
+ // lines below are individually idempotent.
517
+ // Above the gate: the tap provider is a process-wide singleton, so an
518
+ // instance that configured and is then dropped without `destroy()` would
519
+ // otherwise leave the singleton pointing at a dead player's queue, and
520
+ // every later player's mix work would dispatch onto it.
521
+ AudioTapProvider.shared.detachFromEngine()
522
+ // The provider is process-wide, so a callback left installed on it outlives
523
+ // this player and would run against the one being torn down.
524
+ AudioTapProvider.shared.onItemReplayGainDataPopulated = nil
525
+ AudioTapProvider.shared.resetMixDispatchTarget()
526
+
527
+ guard self.engine != nil else { return }
528
+ // Tear the prefetcher down BEFORE the cache. Cancelling the
529
+ // prefetcher's Task chain arms cooperative cancellation so no
530
+ // new download dispatches against the about-to-die URLSession;
531
+ // the cache's `tearDown` then invalidates the session, which
532
+ // is what actually kills any already-suspended
533
+ // URLSessionDownloadTask mid-flight.
534
+ self.lookaheadCachePrefetcher?.tearDown()
535
+ self.lookaheadCachePrefetcher = nil
536
+ // Before the cache: a connection mid-response holds an open handle on a
537
+ // cache file, and the listener is what owns those connections.
538
+ self.readThroughServer?.stop()
539
+ self.readThroughServer = nil
540
+ self.lookaheadCache?.tearDown()
541
+ self.lookaheadCache = nil
542
+ // Drop the mirror chain rather than cancelling it: a receiver call already
543
+ // on the wire is left to finish, and the next session starts its own chain
544
+ // instead of queueing behind a torn-down one.
545
+ self.castMirrorChain = nil
546
+
547
+ self.tearDownGaplessObservers()
548
+ self.tearDownEventObservers()
549
+ self.removeAppLifecycleObservers()
550
+ self.networkMonitor?.stop()
551
+ self.networkMonitor = nil
552
+ self.audioSession.tearDownObservers()
553
+ self.audioSession.onInterruption = nil
554
+ self.audioSession.onRouteChange = nil
555
+ self.audioSession.onError = nil
556
+ self.nowPlayingInfo.clear()
557
+ self.remoteCommands.uninstall()
558
+ self.remoteCommands.onCommand = nil
559
+ self.artworkResolver.reset()
560
+ // The tap provider is a process-wide singleton: drop its pointer at the
561
+ // engine and put its dispatch target back on main, so the next
562
+ // `configure` starts from the clean slate its contract promises.
563
+ // CarPlay coordinator state lives for process lifetime. The
564
+ // bridge slots survive destroy — the subscription is owned by
565
+ // the JS consumer (re-registering replaces the slot), matching
566
+ // the documented Android behaviour. Browse data, in-flight
567
+ // resolver promises, and the artwork cache reset with the
568
+ // configure cycle.
569
+ let coordinator = CarPlayCoordinator.shared
570
+ coordinator.dataProvider.setSnapshot(nil)
571
+ coordinator.queueProvider.setSnapshot(nil)
572
+ coordinator.skipToIndexHandler = nil
573
+ coordinator.onCarAttached = nil
574
+ coordinator.pending.clear()
575
+ coordinator.loader.clearCache()
576
+ // engine.release() drains the underlying AVQueuePlayer's items +
577
+ // nils its mix provider.
578
+ self.engine?.release()
579
+ self.engine = nil
580
+ self.player = nil
581
+ self.sleepTimerFading = false
582
+ self.queueState.clear()
583
+ // The clear emits nothing, so a mirror built from `onQueueChange` deltas
584
+ // would splice the next delta onto the queue this just dropped. Moving
585
+ // the revision past the next emit's value is what a subscriber reads as
586
+ // a missed event, and re-reads.
587
+ self.queueRevision += 1
588
+ self.currentTrackIndex = -1
589
+ self.currentTrackSource = nil
590
+ self.currentBufferState = .empty
591
+ self.lastEmittedBufferState = nil
592
+ self.lastBufferStateQueueItemId = nil
593
+ self.currentFullyBuffered = false
594
+ self.lastEmittedFullyBuffered = nil
595
+ self.hasStartedPlaying = false
596
+ self.wantsToPlay = false
597
+ self.isInterrupted = false
598
+ self.isRecoveringFromStall = false
599
+ self.engineReconcileDeferred = false
600
+ self.lastEmittedQueueItemId = nil
601
+ self.didPlayToEndPending = false
602
+ // `engine` + `player` are both nil now, so `activeMediaItem` is
603
+ // nil: refresh emits null + cancels in-flight extractor Tasks via
604
+ // the generation token.
605
+ self.refreshNowPlayingFormatForActiveItem()
606
+ // `lastErrorQueueItemId` / `lastErrorCode` reset alongside the
607
+ // player so a subsequent error on a different item still fires
608
+ // (the dedup is per-item).
609
+ self.lastErrorQueueItemId = nil
610
+ self.lastErrorCode = nil
611
+ self.reportedFailureQueueItemIds.removeAll()
612
+ self.pendingRetryQueueItemId = nil
613
+ self.retryAttemptsRemaining.removeAll()
614
+ self.lastReportedState = .none
615
+ self.reachedQueueEnd = false
616
+ self.pendingTrackChangeReason = nil
617
+ self.pendingStateChangeReason = nil
618
+ self.audioSession.deactivate()
619
+ // Tear down the cast event bridge — disposers unsubscribe from
620
+ // both the global router and any active session's state listener.
621
+ self.castEventBridge.stop()
622
+ // Tear down the cast lockscreen mirror.
623
+ CastNowPlayingController.shared.stop()
624
+ // Final (false, false) emit so any still-registered subscriber
625
+ // sees the player is gone. JS hooks dispose their listener on
626
+ // unmount; this covers stragglers + native-side subscribers.
627
+ self.recomputeCapabilities()
628
+ }
629
+
630
+ /// Attach the KVO chain that powers the stalling-flip gapless
631
+ /// pattern: observe `player.currentItem` for item transitions,
632
+ /// and for each current item observe `status` so we can flip
633
+ /// `automaticallyWaitsToMinimizeStalling` to false the moment
634
+ /// the current item reaches `.readyToPlay` while
635
+ /// `gaplessFlipArmed == true`. Safe to call repeatedly — previous
636
+ /// observers are invalidated first.
637
+ ///
638
+ /// `.new`-only KVO: `.initial` is deliberately NOT used. `.initial`
639
+ /// fires synchronously during observer registration on whatever
640
+ /// thread the install runs from, which can land mid-mutation and
641
+ /// skip the lib's debounce step (`pendingStateChangeReason`
642
+ /// consumption, `lastEmittedQueueItemId` dedup). The explicit
643
+ /// `playerQueue.async` bootstrap below replicates `.initial` semantics
644
+ /// by funnelling through the same handler the `.new` callback
645
+ /// uses, but on a stable post-registration state. See NOTES.md §18.
646
+ internal func installGaplessObservers(on player: AVQueuePlayer) {
647
+ tearDownGaplessObservers()
648
+
649
+ currentItemObserver = player.observe(
650
+ \.currentItem, options: [.new]
651
+ ) { [weak self] _, _ in
652
+ // KVO fires on AVFoundation's internal queue; funnel onto the queue
653
+ // that owns player state, like every other player mutation.
654
+ guard let self else { return }
655
+ self.playerQueue.async {
656
+ self.attachStatusObserverIfArmed()
657
+ self.handleCurrentItemDidChange()
658
+ }
659
+ }
660
+ // Bootstrap to replicate what `.initial` would have delivered. The async
661
+ // hop matches the `.new` callback body's dispatch shape so a fresh-install
662
+ // cur-item is observed identically to a subsequent transition.
663
+ playerQueue.async { [weak self] in
664
+ guard let self else { return }
665
+ self.attachStatusObserverIfArmed()
666
+ self.handleCurrentItemDidChange()
667
+ }
668
+ }
669
+
670
+ private func attachStatusObserverIfArmed() {
671
+ currentItemStatusObserver?.invalidate()
672
+ currentItemStatusObserver = nil
673
+ currentItemDurationObserver?.invalidate()
674
+ currentItemDurationObserver = nil
675
+ tearDownBufferObservers()
676
+ guard let item = self.player?.currentItem else {
677
+ // No current item (empty queue / torn down) — settle to empty.
678
+ recomputeBufferState()
679
+ return
680
+ }
681
+
682
+ // A local file that does not exist is deterministically unplayable, and its
683
+ // `AVURLAsset` load can stall in `.unknown` indefinitely under host load —
684
+ // the item never reaches `.failed`, so the `\.status` observer below would
685
+ // never surface it and the queue sits buffering with no error. Fail it now
686
+ // and install no observer for this dead item: a status observer that did
687
+ // eventually reach `.failed` would re-report the same source under a
688
+ // second, different error code. A retry re-seats
689
+ // the item and re-runs this check, so a source that appears is picked up
690
+ // then. Pausing first halts the AVQueuePlayer chain-advance the same way the
691
+ // `.failed` branch does. Read the asset's own URL, the one the player loads.
692
+ if let asset = item.asset as? AVURLAsset, asset.url.isFileURL,
693
+ !FileManager.default.fileExists(atPath: asset.url.path) {
694
+ self.player?.pause()
695
+ let missing = NSError(
696
+ domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist,
697
+ userInfo: [NSLocalizedDescriptionKey: "The requested file does not exist."])
698
+ self.dispatchErrorOrRetry(
699
+ item: item, underlying: missing, nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED")
700
+ return
701
+ }
702
+
703
+ // Two distinct concerns share this KVO:
704
+ // 1. `.readyToPlay` flips `automaticallyWaitsToMinimizeStalling`
705
+ // back to false (initial-buffer leniency one-shot).
706
+ // 2. `.failed` surfaces an `onError` event — the only hook that
707
+ // catches initial-load failures (closed port, 4xx/5xx HTTP,
708
+ // malformed asset). Mid-playback failures arrive separately
709
+ // via `AVPlayerItemFailedToPlayToEndTime`.
710
+ //
711
+ // `.new`-only KVO + synchronous bootstrap. The bootstrap is sync
712
+ // (not a `playerQueue.async` hop like the sibling `\.currentItem` /
713
+ // `\.timeControlStatus` bootstraps) because this method is itself
714
+ // called on `playerQueue` (from the `\.currentItem` KVO body's hop)
715
+ // — running the bootstrap inline catches a cache-hit path
716
+ // where the asset resolves to `.readyToPlay` before observer
717
+ // install would otherwise leave `gaplessFlipArmed` set forever.
718
+ //
719
+ // Re-entrancy: a `.failed` immediate resolution dispatches through
720
+ // `handleCurrentItemFailedToLoad` → `dispatchErrorOrRetry`, which
721
+ // may schedule a retry that calls `fullRebuildPlayerQueue`. The
722
+ // rebuild's `tearDownGaplessObservers` invalidates the just-
723
+ // installed observer reference; the synchronous bootstrap's local
724
+ // `let observer` reference still holds but its observation is
725
+ // disconnected. Safe — the inflight bootstrap path completes,
726
+ // returns to `attachStatusObserverIfArmed`, returns to the parent
727
+ // KVO body, and the rebuild's freshly-installed observer takes
728
+ // over.
729
+ currentItemStatusObserver = item.observe(
730
+ \.status, options: [.new]
731
+ ) { [weak self] item, _ in
732
+ guard let self else { return }
733
+ self.playerQueue.async {
734
+ self.dispatchItemStatus(item)
735
+ }
736
+ }
737
+ self.dispatchItemStatus(item)
738
+ currentItemDurationObserver = item.observe(
739
+ \.duration, options: [.new]
740
+ ) { [weak self] _, _ in
741
+ guard let self else { return }
742
+ self.playerQueue.async {
743
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
744
+ }
745
+ }
746
+ installBufferObservers(on: item)
747
+ }
748
+
749
+ /// KVO on the gapless current item's three native playback-buffer flags.
750
+ /// Any change recomputes the `BufferState` and the fully-buffered flag on
751
+ /// `playerQueue`, ahead of the next progress tick.
752
+ private func installBufferObservers(on item: AVPlayerItem) {
753
+ let onChange: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
754
+ self?.playerQueue.async {
755
+ self?.recomputeBufferState()
756
+ self?.recomputeFullyBuffered()
757
+ }
758
+ }
759
+ bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new], changeHandler: onChange)
760
+ bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onChange)
761
+ bufferFullObserver = item.observe(\.isPlaybackBufferFull, options: [.new], changeHandler: onChange)
762
+ recomputeBufferState()
763
+ recomputeFullyBuffered()
764
+ }
765
+
766
+ private func tearDownBufferObservers() {
767
+ bufferEmptyObserver?.invalidate(); bufferEmptyObserver = nil
768
+ bufferKeepUpObserver?.invalidate(); bufferKeepUpObserver = nil
769
+ bufferFullObserver?.invalidate(); bufferFullObserver = nil
770
+ }
771
+
772
+ /// Single source of truth for the per-item `\.status` KVO body.
773
+ /// Called both from the observer callback and from the synchronous
774
+ /// bootstrap in `attachStatusObserverIfArmed`.
775
+ private func dispatchItemStatus(_ item: AVPlayerItem) {
776
+ switch item.status {
777
+ case .readyToPlay:
778
+ if self.gaplessFlipArmed, let player = self.player {
779
+ player.automaticallyWaitsToMinimizeStalling = false
780
+ self.gaplessFlipArmed = false
781
+ }
782
+ // The asset's audio track + format descriptions are reliably
783
+ // loadable by the time we hit `.readyToPlay`. The post-track-
784
+ // change null-emit may race ahead of asset metadata load on
785
+ // first hit, so we re-resolve here to land the real format.
786
+ self.refreshNowPlayingFormatForActiveItem()
787
+ // Streaming-item audioMix catch-up for the gapless engine.
788
+ // engine.setItems / insertItems install the mix at insert
789
+ // time only for items whose `tracks` key is already loaded;
790
+ // streaming items insert with `audioMix == nil` and pick up
791
+ // their mix here once `.readyToPlay` confirms tracks have
792
+ // loaded. The crossfade engine has a parallel catch-up path
793
+ // through its `engineActiveItemFormatChanged` delegate hook —
794
+ // both call into `refreshActiveItemMixes`, which is
795
+ // idempotent on items that already carry a tap, so a double-
796
+ // fire is harmless.
797
+ AudioTapProvider.shared.refreshActiveItemMixes()
798
+ // Duration is only reliably readable once the item is `.readyToPlay`.
799
+ // Re-publish now-playing so the lock-screen scrubber gets a duration on
800
+ // first load + auto-advance (not just after a manual skip, which is the
801
+ // only path that otherwise forces a state change that re-publishes it).
802
+ self.nowPlayingInfo.refreshPositionAndRate(self.nowPlayingSnapshot())
803
+ case .failed:
804
+ // Pause synchronously BEFORE surfacing the typed error. Without
805
+ // this, AVQueuePlayer treats `.failed` as end-of-item and chain-
806
+ // advances through subsequent items in the queue, walking
807
+ // `currentTrackIndex` past the failure point while the JS error
808
+ // event is still in flight. Pausing halts that walk so the
809
+ // consumer sees the error against the failing track and can
810
+ // decide whether to skip / retry / surface UI.
811
+ self.player?.pause()
812
+ self.handleCurrentItemFailedToLoad(item)
813
+ default:
814
+ break
815
+ }
816
+ }
817
+
818
+ /// Surface a `.failed` AVPlayerItem as an `onError` + state ERROR.
819
+ /// Idempotent per (queueItemId, standardized-code) so AVPlayer's
820
+ /// internal item-rebuild during retries doesn't bypass dedup. A
821
+ /// retry that flips the same item to a DIFFERENT error code still
822
+ /// surfaces because the code is part of the dedup key.
823
+ ///
824
+ /// `nativeDomain` on the emitted error is stamped to
825
+ /// `PLAYER_ITEM_LOAD_FAILED` so JS consumers can distinguish item-
826
+ /// load failures (the raw asset never reached `.readyToPlay`) from
827
+ /// mid-playback failures (which arrive via
828
+ /// `AVPlayerItemFailedToPlayToEndTime` and keep the underlying
829
+ /// `NSError.domain`). The underlying NSError code + message remain
830
+ /// available via `nativeCode` + `nativeMessage`.
831
+ private func handleCurrentItemFailedToLoad(_ item: AVPlayerItem) {
832
+ guard item.status == .failed else { return }
833
+ let underlying = item.error as NSError?
834
+ dispatchErrorOrRetry(
835
+ item: item,
836
+ underlying: underlying,
837
+ nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED"
838
+ )
839
+ }
840
+
841
+ /// Shared dispatch path for `handleCurrentItemFailedToLoad` +
842
+ /// `handlePlayerItemFailedToPlayToEndTime`. Encapsulates: (1)
843
+ /// classifier, (2) qid-fallback to fresh UUID for raw items, (3)
844
+ /// (queueItemId, code) dedup, (4) auto-retry path with dedup
845
+ /// clear-on-retry, (5) terminal `errorListeners.forEach` +
846
+ /// emitState(.error). Single chokepoint so the dedup + retry
847
+ /// contracts can't drift between callers.
848
+ ///
849
+ /// `nativeDomainOverride` lets a caller stamp a stable lib-defined
850
+ /// SCREAMING_SNAKE_CASE marker on the emitted `nativeDomain` (e.g.
851
+ /// `PLAYER_ITEM_LOAD_FAILED` from the `.failed`-status path) so JS
852
+ /// consumers can branch on the cause without parsing raw native
853
+ /// domain strings. Nil preserves the underlying `NSError.domain`.
854
+ internal func dispatchErrorOrRetry(
855
+ item: AVPlayerItem?,
856
+ underlying: NSError?,
857
+ nativeDomainOverride: String? = nil
858
+ ) {
859
+ let mapped = PlaybackErrorMapping.classify(underlying)
860
+ // Fresh UUID fallback per fire (NOT a static sentinel) when
861
+ // queueItemId is missing — two unrelated items both lacking the
862
+ // associated-object would otherwise dedup against each other.
863
+ // Production AVQueueBuilder.makeItem always sets queueItemId; this
864
+ // path covers raw `makeItemRaw` items (test stubs).
865
+ let qid = item?.queueItemId ?? UUID().uuidString
866
+ // An echo of a failure whose attempt has not run yet belongs to that
867
+ // attempt, not to the consumer.
868
+ if let qidReal = item?.queueItemId, self.pendingRetryQueueItemId == qidReal { return }
869
+ // Already reported and not yet recovered. The `(item, code)` dedup below
870
+ // cannot carry this on its own: a source the player keeps re-attempting —
871
+ // a server answering a ranged request with a whole body makes AVFoundation
872
+ // do exactly that — fails under more than one code, and codes that
873
+ // alternate defeat a one-slot key. Every recovery path clears the field, so
874
+ // the next thing the consumer asks for reports again.
875
+ if let qidReal = item?.queueItemId, self.reportedFailureQueueItemIds.contains(qidReal) { return }
876
+ // Once the queue has stopped on the current track, the tracks behind it go
877
+ // quiet. The `AVPlayerItemFailedToPlayToEndTime` observer is registered
878
+ // against every item, so a window of unplayable sources reports each of
879
+ // them — turning one `play()` into one error per broken track, which is the
880
+ // flicker the listener sees. They have already been told the track they are
881
+ // on failed; the rest is noise they cannot act on.
882
+ //
883
+ // While playback is healthy this does not fire, so a standby leg failing to
884
+ // preroll under crossfade is still surfaced — the leading leg is playing,
885
+ // nothing has been reported against it, and that failure is news.
886
+ //
887
+ // Nothing is lost either way: a suppressed track reports when the player
888
+ // seats it and it fails as the current one, or when the player drops it and
889
+ // `stopOnDroppedTrack` reports it against the position it skipped.
890
+ if let failedQid = item?.queueItemId,
891
+ let currentQid = self.queueItemIds[safe: self.currentTrackIndex],
892
+ failedQid != currentQid,
893
+ self.reportedFailureQueueItemIds.contains(currentQid) {
894
+ return
895
+ }
896
+ if self.lastErrorQueueItemId == qid && self.lastErrorCode == mapped { return }
897
+ self.lastErrorQueueItemId = qid
898
+ self.lastErrorCode = mapped
899
+ // Auto-retry path: transient errors with retries left schedule
900
+ // a delayed rebuild. Skip JS emit + state.error transition until
901
+ // retries exhausted.
902
+ if PlaybackErrorMapping.isTransient(mapped),
903
+ let qidReal = item?.queueItemId,
904
+ (self.retryAttemptsRemaining[qidReal] ?? 0) > 0 {
905
+ self.retryAttemptsRemaining[qidReal] = self.retryAttemptsRemaining[qidReal]! - 1
906
+ self.pendingRetryQueueItemId = qidReal
907
+ // Clear dedup so the retry-then-fail (if any) fires fresh.
908
+ self.lastErrorQueueItemId = nil
909
+ self.lastErrorCode = nil
910
+ let backoff = self.effectiveRetryBackoffMs()
911
+ playerQueue.asyncAfter(deadline: .now() + .milliseconds(backoff)) {
912
+ [weak self] in self?.retryFailedItem(queueItemId: qidReal)
913
+ }
914
+ return
915
+ }
916
+ // Surface the failing item's queueItemId + URL so consumers can
917
+ // correlate the error to the specific track even when
918
+ // AVQueuePlayer chain-advances past it before the JS event fires.
919
+ // Reuse `qid` (which is the lib-generated UUID fallback for raw
920
+ // items) so the JS-visible field matches the dedup key.
921
+ let failedQid = item?.queueItemId ?? qid
922
+ // The track's own URL. A routed item's asset URL names the read-through
923
+ // server, which tells a consumer nothing about which track failed — and
924
+ // correlating the error to a track is the whole purpose of this field.
925
+ let failedUrl = item?.sourceURL ?? (item?.asset as? AVURLAsset)?.url.absoluteString ?? ""
926
+ let err = self.buildPlaybackError(
927
+ underlying,
928
+ mapped: mapped,
929
+ queueItemId: failedQid,
930
+ url: failedUrl,
931
+ nativeDomainOverride: nativeDomainOverride
932
+ )
933
+ if let failedQid = item?.queueItemId {
934
+ // The consumer now knows this track failed, so the queue holds on it
935
+ // until something asks for playback again.
936
+ self.reportedFailureQueueItemIds.insert(failedQid)
937
+ // `AVQueuePlayer` consumes a failed item, so by now its current item is
938
+ // whatever was installed behind the failure. The gapless status path
939
+ // pauses before it gets here; the crossfade path has no queue player to
940
+ // pause, and neither covers a mid-playback failure.
941
+ self.engine?.pause()
942
+ // A failure that will not recover on its own ends the intent to play, the
943
+ // way reaching the end of the queue does. Left set for a transient
944
+ // failure whose attempts are merely spent, because that is what
945
+ // `handleNetworkRestored` reads through `NetworkRecoveryPolicy` when the
946
+ // network comes back. The resumes that do not consult this flag — a
947
+ // stall recovery, an interruption ending — are held off by the entry
948
+ // above instead, so neither case starts the track behind the failure.
949
+ if !PlaybackErrorMapping.isTransient(mapped) { self.wantsToPlay = false }
950
+ }
951
+ self.errorListeners.forEach { $0(err) }
952
+ self.emitState(.error, reason: .error)
953
+ }
954
+
955
+ internal func tearDownGaplessObservers() {
956
+ currentItemObserver?.invalidate()
957
+ currentItemObserver = nil
958
+ currentItemStatusObserver?.invalidate()
959
+ currentItemStatusObserver = nil
960
+ currentItemDurationObserver?.invalidate()
961
+ currentItemDurationObserver = nil
962
+ tearDownBufferObservers()
963
+ }
964
+
965
+ /// Re-arm the initial-buffer leniency: the next `.readyToPlay`
966
+ /// flips stalling back to false. Called from `fullRebuildPlayerQueue`
967
+ /// (central choke point for "the currentItem is being replaced by
968
+ /// a fresh AVPlayerItem") and from `removeFromQueue` when the
969
+ /// currentItem is dropped and AVQueuePlayer auto-advances to an
970
+ /// item that may not yet be buffered.
971
+ ///
972
+ /// The `\.currentItem` KVO chain already installed in `configure`
973
+ /// handles attaching the status observer once the new currentItem
974
+ /// is in place — we don't attach it synchronously here to avoid
975
+ /// racing against the caller's in-flight `player.insert(...)`
976
+ /// sequence.
977
+ internal func rearmGaplessFlip() {
978
+ guard let player = self.player else { return }
979
+ self.gaplessFlipArmed = true
980
+ player.automaticallyWaitsToMinimizeStalling = true
981
+ }
982
+ }