react-native-queue-player 1.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 (1218) hide show
  1. package/LICENSE +201 -0
  2. package/QueuePlayer.podspec +114 -0
  3. package/README.md +194 -0
  4. package/android/CMakeLists.txt +41 -0
  5. package/android/build.gradle +257 -0
  6. package/android/src/androidTest/java/com/margelo/nitro/queueplayer/SignalsmithStretchNativeTest.kt +136 -0
  7. package/android/src/main/AndroidManifest.xml +173 -0
  8. package/android/src/main/cpp/CMakeLists.txt +221 -0
  9. package/android/src/main/cpp/THIRD_PARTY_LICENSES +128 -0
  10. package/android/src/main/cpp/THIRD_PARTY_VERSIONS.txt +32 -0
  11. package/android/src/main/cpp/airplay2_audio.c +537 -0
  12. package/android/src/main/cpp/airplay2_audio.h +66 -0
  13. package/android/src/main/cpp/airplay2_control.c +680 -0
  14. package/android/src/main/cpp/airplay2_control.h +101 -0
  15. package/android/src/main/cpp/airplay2_crypto.c +548 -0
  16. package/android/src/main/cpp/airplay2_crypto.h +213 -0
  17. package/android/src/main/cpp/airplay2_dmap.c +81 -0
  18. package/android/src/main/cpp/airplay2_dmap.h +53 -0
  19. package/android/src/main/cpp/airplay2_filelog.h +100 -0
  20. package/android/src/main/cpp/airplay2_jni.cpp +579 -0
  21. package/android/src/main/cpp/airplay2_ntp.c +203 -0
  22. package/android/src/main/cpp/airplay2_ntp.h +39 -0
  23. package/android/src/main/cpp/airplay2_pair.c +653 -0
  24. package/android/src/main/cpp/airplay2_pair.h +114 -0
  25. package/android/src/main/cpp/airplay2_plist.c +840 -0
  26. package/android/src/main/cpp/airplay2_plist.h +184 -0
  27. package/android/src/main/cpp/airplay2_ptp.c +509 -0
  28. package/android/src/main/cpp/airplay2_ptp.h +40 -0
  29. package/android/src/main/cpp/airplay2_rtsp.c +693 -0
  30. package/android/src/main/cpp/airplay2_rtsp.h +90 -0
  31. package/android/src/main/cpp/airplay2_session.c +196 -0
  32. package/android/src/main/cpp/airplay2_session.h +197 -0
  33. package/android/src/main/cpp/airplay2_tlv.c +135 -0
  34. package/android/src/main/cpp/airplay2_tlv.h +87 -0
  35. package/android/src/main/cpp/airplay_jni.cpp +589 -0
  36. package/android/src/main/cpp/build-openssl-android.sh +94 -0
  37. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  38. package/android/src/main/cpp/prebuilt/libsodium/arm64-v8a/lib/libsodium.a +0 -0
  39. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/core.h +28 -0
  40. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aegis128l.h +92 -0
  41. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aegis256.h +92 -0
  42. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aes256gcm.h +179 -0
  43. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_chacha20poly1305.h +180 -0
  44. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_xchacha20poly1305.h +100 -0
  45. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth.h +46 -0
  46. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha256.h +70 -0
  47. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha512.h +68 -0
  48. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha512256.h +65 -0
  49. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box.h +177 -0
  50. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box_curve25519xchacha20poly1305.h +164 -0
  51. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box_curve25519xsalsa20poly1305.h +112 -0
  52. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_ed25519.h +104 -0
  53. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_hchacha20.h +36 -0
  54. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_hsalsa20.h +36 -0
  55. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_ristretto255.h +100 -0
  56. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa20.h +36 -0
  57. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa2012.h +36 -0
  58. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa208.h +40 -0
  59. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_generichash.h +84 -0
  60. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_generichash_blake2b.h +122 -0
  61. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash.h +40 -0
  62. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash_sha256.h +60 -0
  63. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash_sha512.h +60 -0
  64. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf.h +53 -0
  65. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_blake2b.h +44 -0
  66. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_hkdf_sha256.h +74 -0
  67. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_hkdf_sha512.h +75 -0
  68. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kx.h +66 -0
  69. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_onetimeauth.h +65 -0
  70. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_onetimeauth_poly1305.h +72 -0
  71. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash.h +147 -0
  72. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_argon2i.h +122 -0
  73. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_argon2id.h +122 -0
  74. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_scryptsalsa208sha256.h +120 -0
  75. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult.h +46 -0
  76. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_curve25519.h +42 -0
  77. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_ed25519.h +51 -0
  78. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_ristretto255.h +43 -0
  79. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox.h +93 -0
  80. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox_xchacha20poly1305.h +70 -0
  81. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox_xsalsa20poly1305.h +69 -0
  82. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretstream_xchacha20poly1305.h +108 -0
  83. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_shorthash.h +41 -0
  84. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_shorthash_siphash24.h +50 -0
  85. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign.h +107 -0
  86. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign_ed25519.h +124 -0
  87. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign_edwards25519sha512batch.h +55 -0
  88. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream.h +59 -0
  89. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_chacha20.h +106 -0
  90. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa20.h +61 -0
  91. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa2012.h +53 -0
  92. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa208.h +56 -0
  93. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_xchacha20.h +61 -0
  94. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_xsalsa20.h +61 -0
  95. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_16.h +23 -0
  96. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_32.h +23 -0
  97. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_64.h +23 -0
  98. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/export.h +57 -0
  99. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes.h +72 -0
  100. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes_internal_random.h +22 -0
  101. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes_sysrandom.h +19 -0
  102. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/runtime.h +55 -0
  103. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/utils.h +179 -0
  104. package/android/src/main/cpp/prebuilt/libsodium/include/sodium/version.h +33 -0
  105. package/android/src/main/cpp/prebuilt/libsodium/include/sodium.h +75 -0
  106. package/android/src/main/cpp/prebuilt/libsodium/x86_64/lib/libsodium.a +0 -0
  107. package/android/src/main/cpp/prebuilt/openssl/arm64-v8a/lib/libcrypto.a +0 -0
  108. package/android/src/main/cpp/prebuilt/openssl/arm64-v8a/lib/libssl.a +0 -0
  109. package/android/src/main/cpp/prebuilt/openssl/x86_64/lib/libcrypto.a +0 -0
  110. package/android/src/main/cpp/prebuilt/openssl/x86_64/lib/libssl.a +0 -0
  111. package/android/src/main/cpp/stretch_jni.cpp +171 -0
  112. package/android/src/main/cpp/third_party/alac/addons/alac_wrapper.cpp +198 -0
  113. package/android/src/main/cpp/third_party/alac/addons/alac_wrapper.h +46 -0
  114. package/android/src/main/cpp/third_party/alac/codec/ALACAudioTypes.h +197 -0
  115. package/android/src/main/cpp/third_party/alac/codec/ALACBitUtilities.c +260 -0
  116. package/android/src/main/cpp/third_party/alac/codec/ALACBitUtilities.h +104 -0
  117. package/android/src/main/cpp/third_party/alac/codec/ALACDecoder.cpp +730 -0
  118. package/android/src/main/cpp/third_party/alac/codec/ALACDecoder.h +65 -0
  119. package/android/src/main/cpp/third_party/alac/codec/ALACEncoder.cpp +1425 -0
  120. package/android/src/main/cpp/third_party/alac/codec/ALACEncoder.h +92 -0
  121. package/android/src/main/cpp/third_party/alac/codec/EndianPortable.c +181 -0
  122. package/android/src/main/cpp/third_party/alac/codec/EndianPortable.h +59 -0
  123. package/android/src/main/cpp/third_party/alac/codec/ag_dec.c +362 -0
  124. package/android/src/main/cpp/third_party/alac/codec/ag_enc.c +370 -0
  125. package/android/src/main/cpp/third_party/alac/codec/aglib.h +81 -0
  126. package/android/src/main/cpp/third_party/alac/codec/dp_dec.c +381 -0
  127. package/android/src/main/cpp/third_party/alac/codec/dp_enc.c +386 -0
  128. package/android/src/main/cpp/third_party/alac/codec/dplib.h +61 -0
  129. package/android/src/main/cpp/third_party/alac/codec/matrix_dec.c +390 -0
  130. package/android/src/main/cpp/third_party/alac/codec/matrix_enc.c +342 -0
  131. package/android/src/main/cpp/third_party/alac/codec/matrixlib.h +80 -0
  132. package/android/src/main/cpp/third_party/crosstools/cross_log.c +95 -0
  133. package/android/src/main/cpp/third_party/crosstools/cross_log.h +25 -0
  134. package/android/src/main/cpp/third_party/crosstools/cross_net.c +1139 -0
  135. package/android/src/main/cpp/third_party/crosstools/cross_net.h +51 -0
  136. package/android/src/main/cpp/third_party/crosstools/cross_ssl.c +371 -0
  137. package/android/src/main/cpp/third_party/crosstools/cross_ssl.h +15 -0
  138. package/android/src/main/cpp/third_party/crosstools/cross_thread.c +134 -0
  139. package/android/src/main/cpp/third_party/crosstools/cross_thread.h +20 -0
  140. package/android/src/main/cpp/third_party/crosstools/cross_util.c +753 -0
  141. package/android/src/main/cpp/third_party/crosstools/cross_util.h +125 -0
  142. package/android/src/main/cpp/third_party/crosstools/platform.c +227 -0
  143. package/android/src/main/cpp/third_party/crosstools/platform.h +127 -0
  144. package/android/src/main/cpp/third_party/curve25519/include/curve25519_dh.h +53 -0
  145. package/android/src/main/cpp/third_party/curve25519/include/ed25519_signature.h +98 -0
  146. package/android/src/main/cpp/third_party/curve25519/include/external_calls.h +36 -0
  147. package/android/src/main/cpp/third_party/curve25519/source/BaseTypes.h +125 -0
  148. package/android/src/main/cpp/third_party/curve25519/source/base_folding8.h +1288 -0
  149. package/android/src/main/cpp/third_party/curve25519/source/curve25519_dh.c +208 -0
  150. package/android/src/main/cpp/third_party/curve25519/source/curve25519_mehdi.c +410 -0
  151. package/android/src/main/cpp/third_party/curve25519/source/curve25519_mehdi.h +175 -0
  152. package/android/src/main/cpp/third_party/curve25519/source/curve25519_order.c +155 -0
  153. package/android/src/main/cpp/third_party/curve25519/source/curve25519_utils.c +153 -0
  154. package/android/src/main/cpp/third_party/curve25519/source/custom_blind.c +27 -0
  155. package/android/src/main/cpp/third_party/curve25519/source/custom_blind.h +11 -0
  156. package/android/src/main/cpp/third_party/curve25519/source/ed25519_sign.c +419 -0
  157. package/android/src/main/cpp/third_party/curve25519/source/ed25519_signature.h +98 -0
  158. package/android/src/main/cpp/third_party/curve25519/source/ed25519_verify.c +313 -0
  159. package/android/src/main/cpp/third_party/curve25519/source/sha512.c +294 -0
  160. package/android/src/main/cpp/third_party/curve25519/source/sha512.h +93 -0
  161. package/android/src/main/cpp/third_party/dmap-parser/dmap_parser.c +545 -0
  162. package/android/src/main/cpp/third_party/dmap-parser/dmap_parser.h +90 -0
  163. package/android/src/main/cpp/third_party/libraop/aes.c +863 -0
  164. package/android/src/main/cpp/third_party/libraop/aes.h +18 -0
  165. package/android/src/main/cpp/third_party/libraop/aes_ctr.c +89 -0
  166. package/android/src/main/cpp/third_party/libraop/aes_ctr.h +59 -0
  167. package/android/src/main/cpp/third_party/libraop/alac.c +1135 -0
  168. package/android/src/main/cpp/third_party/libraop/alac.h +55 -0
  169. package/android/src/main/cpp/third_party/libraop/raop_client.c +1500 -0
  170. package/android/src/main/cpp/third_party/libraop/raop_client.h +178 -0
  171. package/android/src/main/cpp/third_party/libraop/rtsp_client.c +743 -0
  172. package/android/src/main/cpp/third_party/libraop/rtsp_client.h +50 -0
  173. package/android/src/main/cpp/third_party/openssl/include/openssl/__DECC_INCLUDE_EPILOGUE.H +22 -0
  174. package/android/src/main/cpp/third_party/openssl/include/openssl/__DECC_INCLUDE_PROLOGUE.H +26 -0
  175. package/android/src/main/cpp/third_party/openssl/include/openssl/aes.h +111 -0
  176. package/android/src/main/cpp/third_party/openssl/include/openssl/asn1.h +1134 -0
  177. package/android/src/main/cpp/third_party/openssl/include/openssl/asn1.h.in +967 -0
  178. package/android/src/main/cpp/third_party/openssl/include/openssl/asn1err.h +142 -0
  179. package/android/src/main/cpp/third_party/openssl/include/openssl/asn1t.h +946 -0
  180. package/android/src/main/cpp/third_party/openssl/include/openssl/asn1t.h.in +923 -0
  181. package/android/src/main/cpp/third_party/openssl/include/openssl/async.h +104 -0
  182. package/android/src/main/cpp/third_party/openssl/include/openssl/asyncerr.h +29 -0
  183. package/android/src/main/cpp/third_party/openssl/include/openssl/bio.h +1022 -0
  184. package/android/src/main/cpp/third_party/openssl/include/openssl/bio.h.in +999 -0
  185. package/android/src/main/cpp/third_party/openssl/include/openssl/bioerr.h +72 -0
  186. package/android/src/main/cpp/third_party/openssl/include/openssl/blowfish.h +78 -0
  187. package/android/src/main/cpp/third_party/openssl/include/openssl/bn.h +590 -0
  188. package/android/src/main/cpp/third_party/openssl/include/openssl/bnerr.h +47 -0
  189. package/android/src/main/cpp/third_party/openssl/include/openssl/buffer.h +62 -0
  190. package/android/src/main/cpp/third_party/openssl/include/openssl/buffererr.h +25 -0
  191. package/android/src/main/cpp/third_party/openssl/include/openssl/byteorder.h +339 -0
  192. package/android/src/main/cpp/third_party/openssl/include/openssl/camellia.h +117 -0
  193. package/android/src/main/cpp/third_party/openssl/include/openssl/cast.h +71 -0
  194. package/android/src/main/cpp/third_party/openssl/include/openssl/cmac.h +52 -0
  195. package/android/src/main/cpp/third_party/openssl/include/openssl/cmp.h +727 -0
  196. package/android/src/main/cpp/third_party/openssl/include/openssl/cmp.h.in +584 -0
  197. package/android/src/main/cpp/third_party/openssl/include/openssl/cmp_util.h +56 -0
  198. package/android/src/main/cpp/third_party/openssl/include/openssl/cmperr.h +134 -0
  199. package/android/src/main/cpp/third_party/openssl/include/openssl/cms.h +511 -0
  200. package/android/src/main/cpp/third_party/openssl/include/openssl/cms.h.in +413 -0
  201. package/android/src/main/cpp/third_party/openssl/include/openssl/cmserr.h +126 -0
  202. package/android/src/main/cpp/third_party/openssl/include/openssl/comp.h +98 -0
  203. package/android/src/main/cpp/third_party/openssl/include/openssl/comp.h.in +76 -0
  204. package/android/src/main/cpp/third_party/openssl/include/openssl/comperr.h +38 -0
  205. package/android/src/main/cpp/third_party/openssl/include/openssl/conf.h +214 -0
  206. package/android/src/main/cpp/third_party/openssl/include/openssl/conf.h.in +177 -0
  207. package/android/src/main/cpp/third_party/openssl/include/openssl/conf_api.h +46 -0
  208. package/android/src/main/cpp/third_party/openssl/include/openssl/conferr.h +52 -0
  209. package/android/src/main/cpp/third_party/openssl/include/openssl/configuration.h +212 -0
  210. package/android/src/main/cpp/third_party/openssl/include/openssl/configuration.h.in +75 -0
  211. package/android/src/main/cpp/third_party/openssl/include/openssl/conftypes.h +44 -0
  212. package/android/src/main/cpp/third_party/openssl/include/openssl/core.h +236 -0
  213. package/android/src/main/cpp/third_party/openssl/include/openssl/core_dispatch.h +1141 -0
  214. package/android/src/main/cpp/third_party/openssl/include/openssl/core_names.h +575 -0
  215. package/android/src/main/cpp/third_party/openssl/include/openssl/core_names.h.in +126 -0
  216. package/android/src/main/cpp/third_party/openssl/include/openssl/core_object.h +41 -0
  217. package/android/src/main/cpp/third_party/openssl/include/openssl/crmf.h +278 -0
  218. package/android/src/main/cpp/third_party/openssl/include/openssl/crmf.h.in +207 -0
  219. package/android/src/main/cpp/third_party/openssl/include/openssl/crmferr.h +57 -0
  220. package/android/src/main/cpp/third_party/openssl/include/openssl/crypto.h +583 -0
  221. package/android/src/main/cpp/third_party/openssl/include/openssl/crypto.h.in +560 -0
  222. package/android/src/main/cpp/third_party/openssl/include/openssl/cryptoerr.h +56 -0
  223. package/android/src/main/cpp/third_party/openssl/include/openssl/cryptoerr_legacy.h +1466 -0
  224. package/android/src/main/cpp/third_party/openssl/include/openssl/ct.h +573 -0
  225. package/android/src/main/cpp/third_party/openssl/include/openssl/ct.h.in +525 -0
  226. package/android/src/main/cpp/third_party/openssl/include/openssl/cterr.h +45 -0
  227. package/android/src/main/cpp/third_party/openssl/include/openssl/decoder.h +133 -0
  228. package/android/src/main/cpp/third_party/openssl/include/openssl/decodererr.h +28 -0
  229. package/android/src/main/cpp/third_party/openssl/include/openssl/des.h +211 -0
  230. package/android/src/main/cpp/third_party/openssl/include/openssl/dh.h +339 -0
  231. package/android/src/main/cpp/third_party/openssl/include/openssl/dherr.h +59 -0
  232. package/android/src/main/cpp/third_party/openssl/include/openssl/dsa.h +280 -0
  233. package/android/src/main/cpp/third_party/openssl/include/openssl/dsaerr.h +44 -0
  234. package/android/src/main/cpp/third_party/openssl/include/openssl/dtls1.h +57 -0
  235. package/android/src/main/cpp/third_party/openssl/include/openssl/e_os2.h +310 -0
  236. package/android/src/main/cpp/third_party/openssl/include/openssl/e_ostime.h +38 -0
  237. package/android/src/main/cpp/third_party/openssl/include/openssl/ebcdic.h +39 -0
  238. package/android/src/main/cpp/third_party/openssl/include/openssl/ec.h +1588 -0
  239. package/android/src/main/cpp/third_party/openssl/include/openssl/ecdh.h +10 -0
  240. package/android/src/main/cpp/third_party/openssl/include/openssl/ecdsa.h +10 -0
  241. package/android/src/main/cpp/third_party/openssl/include/openssl/ecerr.h +104 -0
  242. package/android/src/main/cpp/third_party/openssl/include/openssl/encoder.h +124 -0
  243. package/android/src/main/cpp/third_party/openssl/include/openssl/encodererr.h +28 -0
  244. package/android/src/main/cpp/third_party/openssl/include/openssl/engine.h +833 -0
  245. package/android/src/main/cpp/third_party/openssl/include/openssl/engineerr.h +63 -0
  246. package/android/src/main/cpp/third_party/openssl/include/openssl/err.h +512 -0
  247. package/android/src/main/cpp/third_party/openssl/include/openssl/err.h.in +501 -0
  248. package/android/src/main/cpp/third_party/openssl/include/openssl/ess.h +128 -0
  249. package/android/src/main/cpp/third_party/openssl/include/openssl/ess.h.in +81 -0
  250. package/android/src/main/cpp/third_party/openssl/include/openssl/esserr.h +32 -0
  251. package/android/src/main/cpp/third_party/openssl/include/openssl/evp.h +2310 -0
  252. package/android/src/main/cpp/third_party/openssl/include/openssl/evperr.h +142 -0
  253. package/android/src/main/cpp/third_party/openssl/include/openssl/fips_names.h +50 -0
  254. package/android/src/main/cpp/third_party/openssl/include/openssl/fipskey.h +41 -0
  255. package/android/src/main/cpp/third_party/openssl/include/openssl/fipskey.h.in +40 -0
  256. package/android/src/main/cpp/third_party/openssl/include/openssl/hmac.h +62 -0
  257. package/android/src/main/cpp/third_party/openssl/include/openssl/hpke.h +169 -0
  258. package/android/src/main/cpp/third_party/openssl/include/openssl/http.h +119 -0
  259. package/android/src/main/cpp/third_party/openssl/include/openssl/httperr.h +56 -0
  260. package/android/src/main/cpp/third_party/openssl/include/openssl/idea.h +82 -0
  261. package/android/src/main/cpp/third_party/openssl/include/openssl/indicator.h +31 -0
  262. package/android/src/main/cpp/third_party/openssl/include/openssl/kdf.h +138 -0
  263. package/android/src/main/cpp/third_party/openssl/include/openssl/kdferr.h +16 -0
  264. package/android/src/main/cpp/third_party/openssl/include/openssl/lhash.h +398 -0
  265. package/android/src/main/cpp/third_party/openssl/include/openssl/lhash.h.in +373 -0
  266. package/android/src/main/cpp/third_party/openssl/include/openssl/macros.h +349 -0
  267. package/android/src/main/cpp/third_party/openssl/include/openssl/md2.h +56 -0
  268. package/android/src/main/cpp/third_party/openssl/include/openssl/md4.h +63 -0
  269. package/android/src/main/cpp/third_party/openssl/include/openssl/md5.h +62 -0
  270. package/android/src/main/cpp/third_party/openssl/include/openssl/mdc2.h +55 -0
  271. package/android/src/main/cpp/third_party/openssl/include/openssl/ml_kem.h +31 -0
  272. package/android/src/main/cpp/third_party/openssl/include/openssl/modes.h +219 -0
  273. package/android/src/main/cpp/third_party/openssl/include/openssl/obj_mac.h +6636 -0
  274. package/android/src/main/cpp/third_party/openssl/include/openssl/objects.h +184 -0
  275. package/android/src/main/cpp/third_party/openssl/include/openssl/objectserr.h +28 -0
  276. package/android/src/main/cpp/third_party/openssl/include/openssl/ocsp.h +483 -0
  277. package/android/src/main/cpp/third_party/openssl/include/openssl/ocsp.h.in +387 -0
  278. package/android/src/main/cpp/third_party/openssl/include/openssl/ocsperr.h +53 -0
  279. package/android/src/main/cpp/third_party/openssl/include/openssl/opensslconf.h +17 -0
  280. package/android/src/main/cpp/third_party/openssl/include/openssl/opensslv.h +114 -0
  281. package/android/src/main/cpp/third_party/openssl/include/openssl/opensslv.h.in +113 -0
  282. package/android/src/main/cpp/third_party/openssl/include/openssl/ossl_typ.h +16 -0
  283. package/android/src/main/cpp/third_party/openssl/include/openssl/param_build.h +63 -0
  284. package/android/src/main/cpp/third_party/openssl/include/openssl/params.h +163 -0
  285. package/android/src/main/cpp/third_party/openssl/include/openssl/pem.h +547 -0
  286. package/android/src/main/cpp/third_party/openssl/include/openssl/pem2.h +19 -0
  287. package/android/src/main/cpp/third_party/openssl/include/openssl/pemerr.h +59 -0
  288. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12.h +366 -0
  289. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12.h.in +343 -0
  290. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12err.h +46 -0
  291. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7.h +430 -0
  292. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7.h.in +359 -0
  293. package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7err.h +63 -0
  294. package/android/src/main/cpp/third_party/openssl/include/openssl/prov_ssl.h +38 -0
  295. package/android/src/main/cpp/third_party/openssl/include/openssl/proverr.h +169 -0
  296. package/android/src/main/cpp/third_party/openssl/include/openssl/provider.h +94 -0
  297. package/android/src/main/cpp/third_party/openssl/include/openssl/quic.h +75 -0
  298. package/android/src/main/cpp/third_party/openssl/include/openssl/rand.h +131 -0
  299. package/android/src/main/cpp/third_party/openssl/include/openssl/randerr.h +70 -0
  300. package/android/src/main/cpp/third_party/openssl/include/openssl/rc2.h +68 -0
  301. package/android/src/main/cpp/third_party/openssl/include/openssl/rc4.h +47 -0
  302. package/android/src/main/cpp/third_party/openssl/include/openssl/rc5.h +79 -0
  303. package/android/src/main/cpp/third_party/openssl/include/openssl/ripemd.h +59 -0
  304. package/android/src/main/cpp/third_party/openssl/include/openssl/rsa.h +615 -0
  305. package/android/src/main/cpp/third_party/openssl/include/openssl/rsaerr.h +107 -0
  306. package/android/src/main/cpp/third_party/openssl/include/openssl/safestack.h +297 -0
  307. package/android/src/main/cpp/third_party/openssl/include/openssl/safestack.h.in +227 -0
  308. package/android/src/main/cpp/third_party/openssl/include/openssl/seed.h +113 -0
  309. package/android/src/main/cpp/third_party/openssl/include/openssl/self_test.h +112 -0
  310. package/android/src/main/cpp/third_party/openssl/include/openssl/sha.h +139 -0
  311. package/android/src/main/cpp/third_party/openssl/include/openssl/srp.h +285 -0
  312. package/android/src/main/cpp/third_party/openssl/include/openssl/srp.h.in +214 -0
  313. package/android/src/main/cpp/third_party/openssl/include/openssl/srtp.h +68 -0
  314. package/android/src/main/cpp/third_party/openssl/include/openssl/ssl.h +2933 -0
  315. package/android/src/main/cpp/third_party/openssl/include/openssl/ssl.h.in +2886 -0
  316. package/android/src/main/cpp/third_party/openssl/include/openssl/ssl2.h +30 -0
  317. package/android/src/main/cpp/third_party/openssl/include/openssl/ssl3.h +358 -0
  318. package/android/src/main/cpp/third_party/openssl/include/openssl/sslerr.h +382 -0
  319. package/android/src/main/cpp/third_party/openssl/include/openssl/sslerr_legacy.h +467 -0
  320. package/android/src/main/cpp/third_party/openssl/include/openssl/stack.h +90 -0
  321. package/android/src/main/cpp/third_party/openssl/include/openssl/store.h +377 -0
  322. package/android/src/main/cpp/third_party/openssl/include/openssl/storeerr.h +49 -0
  323. package/android/src/main/cpp/third_party/openssl/include/openssl/symhacks.h +39 -0
  324. package/android/src/main/cpp/third_party/openssl/include/openssl/thread.h +31 -0
  325. package/android/src/main/cpp/third_party/openssl/include/openssl/tls1.h +1220 -0
  326. package/android/src/main/cpp/third_party/openssl/include/openssl/trace.h +321 -0
  327. package/android/src/main/cpp/third_party/openssl/include/openssl/ts.h +522 -0
  328. package/android/src/main/cpp/third_party/openssl/include/openssl/tserr.h +67 -0
  329. package/android/src/main/cpp/third_party/openssl/include/openssl/txt_db.h +63 -0
  330. package/android/src/main/cpp/third_party/openssl/include/openssl/types.h +248 -0
  331. package/android/src/main/cpp/third_party/openssl/include/openssl/ui.h +407 -0
  332. package/android/src/main/cpp/third_party/openssl/include/openssl/ui.h.in +384 -0
  333. package/android/src/main/cpp/third_party/openssl/include/openssl/uierr.h +38 -0
  334. package/android/src/main/cpp/third_party/openssl/include/openssl/whrlpool.h +62 -0
  335. package/android/src/main/cpp/third_party/openssl/include/openssl/x509.h +1303 -0
  336. package/android/src/main/cpp/third_party/openssl/include/openssl/x509.h.in +1109 -0
  337. package/android/src/main/cpp/third_party/openssl/include/openssl/x509_acert.h +294 -0
  338. package/android/src/main/cpp/third_party/openssl/include/openssl/x509_acert.h.in +199 -0
  339. package/android/src/main/cpp/third_party/openssl/include/openssl/x509_vfy.h +903 -0
  340. package/android/src/main/cpp/third_party/openssl/include/openssl/x509_vfy.h.in +806 -0
  341. package/android/src/main/cpp/third_party/openssl/include/openssl/x509err.h +70 -0
  342. package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3.h +1968 -0
  343. package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3.h.in +1367 -0
  344. package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3err.h +97 -0
  345. package/android/src/main/cpp/third_party/pair_ap/pair-internal.h +390 -0
  346. package/android/src/main/cpp/third_party/pair_ap/pair-tlv.c +221 -0
  347. package/android/src/main/cpp/third_party/pair_ap/pair-tlv.h +77 -0
  348. package/android/src/main/cpp/third_party/pair_ap/pair.c +806 -0
  349. package/android/src/main/cpp/third_party/pair_ap/pair.h +266 -0
  350. package/android/src/main/cpp/third_party/pair_ap/pair_fruit_stub.c +36 -0
  351. package/android/src/main/cpp/third_party/pair_ap/pair_homekit.c +3190 -0
  352. package/android/src/main/cpp/third_party/signalsmith/LICENSE-signalsmith-linear.txt +21 -0
  353. package/android/src/main/cpp/third_party/signalsmith/LICENSE-signalsmith-stretch.txt +21 -0
  354. package/android/src/main/cpp/third_party/signalsmith/THIRD_PARTY_VERSIONS.txt +9 -0
  355. package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/fft.h +1380 -0
  356. package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/linear.h +1075 -0
  357. package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/stft.h +643 -0
  358. package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-stretch.h +1060 -0
  359. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +314 -0
  360. package/android/src/main/java/com/margelo/nitro/queueplayer/ArtworkContentProvider.kt +124 -0
  361. package/android/src/main/java/com/margelo/nitro/queueplayer/AudioFocusErrorMapping.kt +89 -0
  362. package/android/src/main/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactory.kt +60 -0
  363. package/android/src/main/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistry.kt +136 -0
  364. package/android/src/main/java/com/margelo/nitro/queueplayer/BrowseDataProvider.kt +128 -0
  365. package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +35 -0
  366. package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +79 -0
  367. package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +629 -0
  368. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +1350 -0
  369. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeForwardingPlayer.kt +66 -0
  370. package/android/src/main/java/com/margelo/nitro/queueplayer/CustomPresetStore.kt +100 -0
  371. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +348 -0
  372. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +222 -0
  373. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +213 -0
  374. package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerPresets.kt +45 -0
  375. package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +173 -0
  376. package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +96 -0
  377. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +587 -0
  378. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +220 -0
  379. package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +97 -0
  380. package/android/src/main/java/com/margelo/nitro/queueplayer/ListenerRegistry.kt +59 -0
  381. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +343 -0
  382. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +533 -0
  383. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaButtonDispatch.kt +88 -0
  384. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +281 -0
  385. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemMapper.kt +76 -0
  386. package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +66 -0
  387. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +64 -0
  388. package/android/src/main/java/com/margelo/nitro/queueplayer/MultiplexingAudioBufferSink.kt +92 -0
  389. package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +37 -0
  390. package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +166 -0
  391. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingBrowseRequests.kt +70 -0
  392. package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +82 -0
  393. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChain.kt +69 -0
  394. package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +27 -0
  395. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +65 -0
  396. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +75 -0
  397. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +336 -0
  398. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +175 -0
  399. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +127 -0
  400. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +1716 -0
  401. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +755 -0
  402. package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +70 -0
  403. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +131 -0
  404. package/android/src/main/java/com/margelo/nitro/queueplayer/QueuePlayerPackage.kt +22 -0
  405. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +113 -0
  406. package/android/src/main/java/com/margelo/nitro/queueplayer/RadixTwoFFT.kt +176 -0
  407. package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +81 -0
  408. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +84 -0
  409. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainConfig.kt +34 -0
  410. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +44 -0
  411. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +209 -0
  412. package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +34 -0
  413. package/android/src/main/java/com/margelo/nitro/queueplayer/SearchResultCache.kt +72 -0
  414. package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +167 -0
  415. package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchAudioProcessor.kt +225 -0
  416. package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchNative.kt +93 -0
  417. package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +125 -0
  418. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +4242 -0
  419. package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +70 -0
  420. package/android/src/main/java/com/margelo/nitro/queueplayer/UrlSchemes.kt +9 -0
  421. package/android/src/main/java/com/margelo/nitro/queueplayer/VisualizationGate.kt +35 -0
  422. package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +240 -0
  423. package/android/src/main/java/com/margelo/nitro/queueplayer/VisualizerTeeBuilder.kt +63 -0
  424. package/android/src/main/java/com/margelo/nitro/queueplayer/VoiceSearchExtrasParser.kt +103 -0
  425. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/AudioRouteState.kt +202 -0
  426. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/AuthRequiredException.kt +14 -0
  427. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +41 -0
  428. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackends.kt +56 -0
  429. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +122 -0
  430. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastMediaItem.kt +20 -0
  431. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +193 -0
  432. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSinkRouter.kt +95 -0
  433. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +151 -0
  434. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/DiscoveryState.kt +53 -0
  435. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/MetadataAwareSession.kt +17 -0
  436. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/PlaybackStateRouter.kt +65 -0
  437. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/Receiver.kt +35 -0
  438. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +129 -0
  439. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +189 -0
  440. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +123 -0
  441. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Sink.kt +205 -0
  442. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +585 -0
  443. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackendInitializer.kt +43 -0
  444. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscovery.kt +511 -0
  445. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscoveryLifecycleObserver.kt +57 -0
  446. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +199 -0
  447. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayReceiverMapper.kt +42 -0
  448. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +43 -0
  449. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +101 -0
  450. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySink.kt +265 -0
  451. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayTxtParser.kt +203 -0
  452. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +106 -0
  453. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +75 -0
  454. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/CastMediaItemConverter.kt +67 -0
  455. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/CastVolumeForwardingPlayer.kt +156 -0
  456. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +171 -0
  457. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastOptionsProvider.kt +51 -0
  458. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastReceiverMapper.kt +44 -0
  459. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +544 -0
  460. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/MediaInfoBuilder.kt +92 -0
  461. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/RemoteMediaCallbacks.kt +113 -0
  462. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +160 -0
  463. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +15 -0
  464. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +44 -0
  465. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +37 -0
  466. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MimeTypes.kt +27 -0
  467. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +86 -0
  468. package/android/src/main/res/raw/rnqp_placeholder.jpg +0 -0
  469. package/android/src/main/res/values/cast_strings.xml +15 -0
  470. package/android/src/main/res/values/strings.xml +24 -0
  471. package/android/src/main/res/xml/automotive_app_desc.xml +15 -0
  472. package/android/src/test/java/androidx/media3/session/MediaSessionControllerRequestTestSeam.kt +27 -0
  473. package/android/src/test/java/com/margelo/nitro/queueplayer/ArtworkContentProviderTest.kt +124 -0
  474. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioFocusErrorMappingTest.kt +122 -0
  475. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +57 -0
  476. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +264 -0
  477. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +104 -0
  478. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseDataProviderTest.kt +276 -0
  479. package/android/src/test/java/com/margelo/nitro/queueplayer/BufferStateTranslatorTest.kt +58 -0
  480. package/android/src/test/java/com/margelo/nitro/queueplayer/CacheMimeTypesTest.kt +116 -0
  481. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +214 -0
  482. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +180 -0
  483. package/android/src/test/java/com/margelo/nitro/queueplayer/CustomPresetStoreTest.kt +128 -0
  484. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +223 -0
  485. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +274 -0
  486. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +214 -0
  487. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerPresetsTest.kt +89 -0
  488. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +185 -0
  489. package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +112 -0
  490. package/android/src/test/java/com/margelo/nitro/queueplayer/ForwardingPlayerTestAccess.kt +21 -0
  491. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +65 -0
  492. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +154 -0
  493. package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +106 -0
  494. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +183 -0
  495. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +649 -0
  496. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +192 -0
  497. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +389 -0
  498. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemMapperTest.kt +145 -0
  499. package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +81 -0
  500. package/android/src/test/java/com/margelo/nitro/queueplayer/MultiplexingAudioBufferSinkTest.kt +112 -0
  501. package/android/src/test/java/com/margelo/nitro/queueplayer/NotificationProviderTest.kt +86 -0
  502. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +106 -0
  503. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingBrowseRequestsTest.kt +82 -0
  504. package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +192 -0
  505. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +147 -0
  506. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +55 -0
  507. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +187 -0
  508. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +134 -0
  509. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachineTest.kt +157 -0
  510. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +95 -0
  511. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +480 -0
  512. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +171 -0
  513. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +773 -0
  514. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCastBindTest.kt +100 -0
  515. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +315 -0
  516. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceSessionActivityTest.kt +121 -0
  517. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +246 -0
  518. package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +113 -0
  519. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueMutationArithmeticTest.kt +252 -0
  520. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +343 -0
  521. package/android/src/test/java/com/margelo/nitro/queueplayer/RadixTwoFFTTest.kt +109 -0
  522. package/android/src/test/java/com/margelo/nitro/queueplayer/RemoteCommandDeduperTest.kt +106 -0
  523. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +141 -0
  524. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainConfigTest.kt +103 -0
  525. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +469 -0
  526. package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +129 -0
  527. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +83 -0
  528. package/android/src/test/java/com/margelo/nitro/queueplayer/SearchResultCacheTest.kt +128 -0
  529. package/android/src/test/java/com/margelo/nitro/queueplayer/ServiceReadyReasonTest.kt +42 -0
  530. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +521 -0
  531. package/android/src/test/java/com/margelo/nitro/queueplayer/SleepTimerCoreTest.kt +125 -0
  532. package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +27 -0
  533. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +305 -0
  534. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +179 -0
  535. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +497 -0
  536. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +209 -0
  537. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +417 -0
  538. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +473 -0
  539. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +61 -0
  540. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +28 -0
  541. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +89 -0
  542. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +290 -0
  543. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +151 -0
  544. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +69 -0
  545. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +324 -0
  546. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +287 -0
  547. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSourceClassifierTest.kt +82 -0
  548. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +222 -0
  549. package/android/src/test/java/com/margelo/nitro/queueplayer/TransportStateTranslatorTest.kt +287 -0
  550. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizationGateTest.kt +96 -0
  551. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +202 -0
  552. package/android/src/test/java/com/margelo/nitro/queueplayer/VoiceSearchExtrasParserTest.kt +211 -0
  553. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/CastSinkRouterTest.kt +120 -0
  554. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +62 -0
  555. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/PlaybackStateRouterTest.kt +43 -0
  556. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscoveryFilterTest.kt +86 -0
  557. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayTxtParserClassificationTest.kt +34 -0
  558. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/chromecast/CastMediaItemConverterTest.kt +78 -0
  559. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/chromecast/CastVolumeForwardingPlayerTest.kt +176 -0
  560. package/app.plugin.js +466 -0
  561. package/ios/AVPlayerItemQueueItemId.swift +61 -0
  562. package/ios/AVQueueBuilder.swift +200 -0
  563. package/ios/Array+SafeSubscript.swift +6 -0
  564. package/ios/ArtworkLoader.swift +136 -0
  565. package/ios/ArtworkResolver.swift +87 -0
  566. package/ios/AudioCategoryMapping.swift +48 -0
  567. package/ios/AudioSession.swift +220 -0
  568. package/ios/AudioSessionError.swift +44 -0
  569. package/ios/AudioTapProvider.swift +647 -0
  570. package/ios/BiquadCoefficients.swift +80 -0
  571. package/ios/CarPlayBridge.swift +178 -0
  572. package/ios/CarPlayBrowseBuilder.swift +368 -0
  573. package/ios/CarPlayBrowseDataProvider.swift +162 -0
  574. package/ios/CarPlayCoordinator.swift +42 -0
  575. package/ios/CarPlaySceneDelegate.swift +199 -0
  576. package/ios/Cast/AirPlayRouteState.swift +281 -0
  577. package/ios/Cast/Chromecast/ChromecastDiscoveryAdapter.swift +27 -0
  578. package/ios/Cast/Chromecast/ChromecastReceiverMapper.swift +48 -0
  579. package/ios/Cast/Chromecast/ChromecastSession.swift +740 -0
  580. package/ios/Cast/Chromecast/ChromecastSessionAdapter.swift +114 -0
  581. package/ios/Cast/Chromecast/ChromecastSupport.swift +63 -0
  582. package/ios/Cast/Chromecast/MediaInfoBuilder.swift +104 -0
  583. package/ios/Cast/Chromecast/RemoteMediaObserver.swift +147 -0
  584. package/ios/Cast/Core/CastEventBridge.swift +210 -0
  585. package/ios/Cast/Core/CastMediaItem.swift +41 -0
  586. package/ios/Cast/Core/CastNowPlayingController.swift +310 -0
  587. package/ios/Cast/Core/CastReceiverState.swift +22 -0
  588. package/ios/Cast/Core/CastSession.swift +147 -0
  589. package/ios/Cast/Core/CastTransportRouter.swift +162 -0
  590. package/ios/Cast/Core/ChromecastBackend.swift +259 -0
  591. package/ios/Cast/Core/LocalAddressMonitor.swift +110 -0
  592. package/ios/Cast/Core/LocalMediaServer.swift +270 -0
  593. package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +265 -0
  594. package/ios/Cast/Core/MediaHTTPConnection.swift +349 -0
  595. package/ios/Cast/Core/MediaServerHandle.swift +49 -0
  596. package/ios/Cast/Core/MediaTokenRegistry.swift +51 -0
  597. package/ios/Cast/Core/MimeTypes.swift +26 -0
  598. package/ios/Cast/Core/PlaybackStateRouter.swift +123 -0
  599. package/ios/CastManager.swift +493 -0
  600. package/ios/CrossfadeEngine.swift +1343 -0
  601. package/ios/CustomPresetStore.swift +78 -0
  602. package/ios/EQProcessor.swift +321 -0
  603. package/ios/EQTap.swift +241 -0
  604. package/ios/Equalizer.swift +321 -0
  605. package/ios/EqualizerPresets.swift +48 -0
  606. package/ios/FFTProcessor.swift +237 -0
  607. package/ios/GaplessEngine.swift +385 -0
  608. package/ios/InputGuards.swift +63 -0
  609. package/ios/InterruptionEventMapping.swift +51 -0
  610. package/ios/ListenerRegistry.swift +73 -0
  611. package/ios/LookaheadCache.swift +833 -0
  612. package/ios/LookaheadCacheEviction.swift +83 -0
  613. package/ios/LookaheadCachePrefetcher.swift +503 -0
  614. package/ios/MetadataReader.swift +555 -0
  615. package/ios/MilestoneTracker.swift +63 -0
  616. package/ios/NowPlayingFormatExtractor.swift +362 -0
  617. package/ios/NowPlayingInfo.swift +190 -0
  618. package/ios/OutputRouteMonitor.swift +125 -0
  619. package/ios/PendingBrowseRequests.swift +62 -0
  620. package/ios/PitchAlgorithm.swift +24 -0
  621. package/ios/PlaceholderArtwork.swift +46 -0
  622. package/ios/PlaybackEngine.swift +319 -0
  623. package/ios/PlaybackErrorMapping.swift +195 -0
  624. package/ios/PlaybackModeStateMachine.swift +159 -0
  625. package/ios/PlayerStateDerivation.swift +45 -0
  626. package/ios/ProgressEmissionGate.swift +54 -0
  627. package/ios/QueueMutationArithmetic.swift +129 -0
  628. package/ios/QueueSkipArithmetic.swift +109 -0
  629. package/ios/QueueState.swift +49 -0
  630. package/ios/RemoteCommands.swift +229 -0
  631. package/ios/ReplayGainData.swift +58 -0
  632. package/ios/ReplayGainExtractor.swift +82 -0
  633. package/ios/ReplayGainProcessor.swift +46 -0
  634. package/ios/Resources/RNQP-placeholder.jpg +0 -0
  635. package/ios/Resources/silence.m4a +0 -0
  636. package/ios/Siri/PendingIntentBuffer.swift +100 -0
  637. package/ios/Siri/PlayMediaIntentHandler.swift +283 -0
  638. package/ios/Siri/QueuePlayerMediaIntents.swift +45 -0
  639. package/ios/Siri/VoiceDonation.swift +50 -0
  640. package/ios/SleepTimerCore.swift +128 -0
  641. package/ios/StreamingBitrateProbe.swift +318 -0
  642. package/ios/Tests/AVQueueBuilderTests.swift +430 -0
  643. package/ios/Tests/ArtworkResolverTests.swift +540 -0
  644. package/ios/Tests/AudioSessionTests.swift +222 -0
  645. package/ios/Tests/AudioTapProviderReplayGainTests.swift +136 -0
  646. package/ios/Tests/AudioTapProviderVisualizationFlagTests.swift +104 -0
  647. package/ios/Tests/BiquadCoefficientsTests.swift +84 -0
  648. package/ios/Tests/CarPlayBridgeTests.swift +91 -0
  649. package/ios/Tests/CarPlayBrowseBuilderTests.swift +221 -0
  650. package/ios/Tests/CarPlayBrowseDataProviderTests.swift +218 -0
  651. package/ios/Tests/CastNowPlayingControllerTests.swift +204 -0
  652. package/ios/Tests/CrossfadeEngineStubTests.swift +43 -0
  653. package/ios/Tests/EQProcessorTests.swift +302 -0
  654. package/ios/Tests/EQTapTests.swift +49 -0
  655. package/ios/Tests/EqualizerAudioMixProviderTests.swift +110 -0
  656. package/ios/Tests/EqualizerHybridTests.swift +303 -0
  657. package/ios/Tests/EqualizerPresetsTests.swift +69 -0
  658. package/ios/Tests/FFTProcessorTests.swift +303 -0
  659. package/ios/Tests/Fixtures/silence-aac-128.m4a +0 -0
  660. package/ios/Tests/Fixtures/silence-cbr-128.mp3 +0 -0
  661. package/ios/Tests/Fixtures/silence-vbr.mp3 +0 -0
  662. package/ios/Tests/GaplessEngineLifecycleTests.swift +160 -0
  663. package/ios/Tests/InputGuardsTests.swift +75 -0
  664. package/ios/Tests/InterruptionEventMappingTests.swift +91 -0
  665. package/ios/Tests/LookaheadCacheEvictionTests.swift +217 -0
  666. package/ios/Tests/LookaheadCachePrefetcherTests.swift +630 -0
  667. package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +247 -0
  668. package/ios/Tests/LookaheadCacheTests.swift +717 -0
  669. package/ios/Tests/MediaHTTPConnectionTests.swift +163 -0
  670. package/ios/Tests/MetadataReaderTests.swift +605 -0
  671. package/ios/Tests/MilestoneTrackerTests.swift +76 -0
  672. package/ios/Tests/NowPlayingFormatExtractorTests.swift +158 -0
  673. package/ios/Tests/NowPlayingInfoTests.swift +292 -0
  674. package/ios/Tests/PendingBrowseRequestsTests.swift +69 -0
  675. package/ios/Tests/PendingIntentBufferTests.swift +164 -0
  676. package/ios/Tests/PlaybackEngineProtocolTests.swift +92 -0
  677. package/ios/Tests/PlaybackErrorMappingTests.swift +179 -0
  678. package/ios/Tests/PlaybackModeStateMachineTests.swift +40 -0
  679. package/ios/Tests/PlaybackStateRouterTests.swift +178 -0
  680. package/ios/Tests/PlayerStateDerivationTests.swift +69 -0
  681. package/ios/Tests/ProgressEmissionGateTests.swift +113 -0
  682. package/ios/Tests/QueueMutationArithmeticTests.swift +245 -0
  683. package/ios/Tests/RemoteCommandsTests.swift +147 -0
  684. package/ios/Tests/ReplayGainExtractorTests.swift +197 -0
  685. package/ios/Tests/ReplayGainProcessorTests.swift +90 -0
  686. package/ios/Tests/SkipCapabilityTests.swift +98 -0
  687. package/ios/Tests/SkipIndexTests.swift +259 -0
  688. package/ios/Tests/SleepTimerCoreTests.swift +98 -0
  689. package/ios/Tests/StreamingBitrateProbeTests.swift +29 -0
  690. package/ios/Tests/TrackSourceClassifierTests.swift +50 -0
  691. package/ios/Tests/VoiceDonationTests.swift +26 -0
  692. package/ios/TrackPlayer.swift +4766 -0
  693. package/ios/Visualizer.swift +274 -0
  694. package/ios/WindowApplicationSceneDelegate.swift +59 -0
  695. package/ios/tests-harness/Podfile +56 -0
  696. package/ios/tests-harness/TestHost/App.swift +12 -0
  697. package/ios/tests-harness/TestHost/Info.plist +37 -0
  698. package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +472 -0
  699. package/ios/tests-harness/TestHost.xcodeproj/xcshareddata/xcschemes/TestHost.xcscheme +68 -0
  700. package/ios/tests-harness/package.json +10 -0
  701. package/ios/tests-harness/scripts/seed-xcodeproj.rb +118 -0
  702. package/lib/module/CastManager.nitro.js +4 -0
  703. package/lib/module/CastManager.nitro.js.map +1 -0
  704. package/lib/module/Equalizer.nitro.js +4 -0
  705. package/lib/module/Equalizer.nitro.js.map +1 -0
  706. package/lib/module/TrackPlayer.nitro.js +4 -0
  707. package/lib/module/TrackPlayer.nitro.js.map +1 -0
  708. package/lib/module/Visualizer.nitro.js +4 -0
  709. package/lib/module/Visualizer.nitro.js.map +1 -0
  710. package/lib/module/cast/index.js +156 -0
  711. package/lib/module/cast/index.js.map +1 -0
  712. package/lib/module/clients.js +43 -0
  713. package/lib/module/clients.js.map +1 -0
  714. package/lib/module/equalizer.js +85 -0
  715. package/lib/module/equalizer.js.map +1 -0
  716. package/lib/module/hooks/index.js +16 -0
  717. package/lib/module/hooks/index.js.map +1 -0
  718. package/lib/module/hooks/useActiveTrack.js +50 -0
  719. package/lib/module/hooks/useActiveTrack.js.map +1 -0
  720. package/lib/module/hooks/useAudioRoute.js +60 -0
  721. package/lib/module/hooks/useAudioRoute.js.map +1 -0
  722. package/lib/module/hooks/useBufferState.js +34 -0
  723. package/lib/module/hooks/useBufferState.js.map +1 -0
  724. package/lib/module/hooks/useCanSkip.js +67 -0
  725. package/lib/module/hooks/useCanSkip.js.map +1 -0
  726. package/lib/module/hooks/useCast.js +107 -0
  727. package/lib/module/hooks/useCast.js.map +1 -0
  728. package/lib/module/hooks/useEqualizer.js +91 -0
  729. package/lib/module/hooks/useEqualizer.js.map +1 -0
  730. package/lib/module/hooks/useFullyBuffered.js +33 -0
  731. package/lib/module/hooks/useFullyBuffered.js.map +1 -0
  732. package/lib/module/hooks/useLookaheadCache.js +49 -0
  733. package/lib/module/hooks/useLookaheadCache.js.map +1 -0
  734. package/lib/module/hooks/useNowPlayingFormat.js +42 -0
  735. package/lib/module/hooks/useNowPlayingFormat.js.map +1 -0
  736. package/lib/module/hooks/usePlaybackState.js +36 -0
  737. package/lib/module/hooks/usePlaybackState.js.map +1 -0
  738. package/lib/module/hooks/useProgress.js +31 -0
  739. package/lib/module/hooks/useProgress.js.map +1 -0
  740. package/lib/module/hooks/useSleepTimer.js +65 -0
  741. package/lib/module/hooks/useSleepTimer.js.map +1 -0
  742. package/lib/module/hooks/useVisualizerState.js +99 -0
  743. package/lib/module/hooks/useVisualizerState.js.map +1 -0
  744. package/lib/module/index.js +37 -0
  745. package/lib/module/index.js.map +1 -0
  746. package/lib/module/package.json +1 -0
  747. package/lib/module/playbackService.js +174 -0
  748. package/lib/module/playbackService.js.map +1 -0
  749. package/lib/module/types.js +592 -0
  750. package/lib/module/types.js.map +1 -0
  751. package/lib/typescript/CastManager.nitro.d.ts +154 -0
  752. package/lib/typescript/CastManager.nitro.d.ts.map +1 -0
  753. package/lib/typescript/Equalizer.nitro.d.ts +30 -0
  754. package/lib/typescript/Equalizer.nitro.d.ts.map +1 -0
  755. package/lib/typescript/TrackPlayer.nitro.d.ts +584 -0
  756. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -0
  757. package/lib/typescript/Visualizer.nitro.d.ts +36 -0
  758. package/lib/typescript/Visualizer.nitro.d.ts.map +1 -0
  759. package/lib/typescript/cast/index.d.ts +65 -0
  760. package/lib/typescript/cast/index.d.ts.map +1 -0
  761. package/lib/typescript/clients.d.ts +9 -0
  762. package/lib/typescript/clients.d.ts.map +1 -0
  763. package/lib/typescript/equalizer.d.ts +45 -0
  764. package/lib/typescript/equalizer.d.ts.map +1 -0
  765. package/lib/typescript/hooks/index.d.ts +22 -0
  766. package/lib/typescript/hooks/index.d.ts.map +1 -0
  767. package/lib/typescript/hooks/useActiveTrack.d.ts +21 -0
  768. package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -0
  769. package/lib/typescript/hooks/useAudioRoute.d.ts +13 -0
  770. package/lib/typescript/hooks/useAudioRoute.d.ts.map +1 -0
  771. package/lib/typescript/hooks/useBufferState.d.ts +18 -0
  772. package/lib/typescript/hooks/useBufferState.d.ts.map +1 -0
  773. package/lib/typescript/hooks/useCanSkip.d.ts +43 -0
  774. package/lib/typescript/hooks/useCanSkip.d.ts.map +1 -0
  775. package/lib/typescript/hooks/useCast.d.ts +32 -0
  776. package/lib/typescript/hooks/useCast.d.ts.map +1 -0
  777. package/lib/typescript/hooks/useEqualizer.d.ts +20 -0
  778. package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -0
  779. package/lib/typescript/hooks/useFullyBuffered.d.ts +15 -0
  780. package/lib/typescript/hooks/useFullyBuffered.d.ts.map +1 -0
  781. package/lib/typescript/hooks/useLookaheadCache.d.ts +13 -0
  782. package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -0
  783. package/lib/typescript/hooks/useNowPlayingFormat.d.ts +18 -0
  784. package/lib/typescript/hooks/useNowPlayingFormat.d.ts.map +1 -0
  785. package/lib/typescript/hooks/usePlaybackState.d.ts +9 -0
  786. package/lib/typescript/hooks/usePlaybackState.d.ts.map +1 -0
  787. package/lib/typescript/hooks/useProgress.d.ts +15 -0
  788. package/lib/typescript/hooks/useProgress.d.ts.map +1 -0
  789. package/lib/typescript/hooks/useSleepTimer.d.ts +26 -0
  790. package/lib/typescript/hooks/useSleepTimer.d.ts.map +1 -0
  791. package/lib/typescript/hooks/useVisualizerState.d.ts +40 -0
  792. package/lib/typescript/hooks/useVisualizerState.d.ts.map +1 -0
  793. package/lib/typescript/index.d.ts +14 -0
  794. package/lib/typescript/index.d.ts.map +1 -0
  795. package/lib/typescript/package.json +1 -0
  796. package/lib/typescript/playbackService.d.ts +140 -0
  797. package/lib/typescript/playbackService.d.ts.map +1 -0
  798. package/lib/typescript/types.d.ts +1215 -0
  799. package/lib/typescript/types.d.ts.map +1 -0
  800. package/nitro.json +53 -0
  801. package/nitrogen/generated/android/c++/JAudioCategoryOptions.hpp +65 -0
  802. package/nitrogen/generated/android/c++/JAudioCodec.hpp +73 -0
  803. package/nitrogen/generated/android/c++/JAudioContainer.hpp +76 -0
  804. package/nitrogen/generated/android/c++/JAudioContentType.hpp +58 -0
  805. package/nitrogen/generated/android/c++/JAudioRoute.hpp +75 -0
  806. package/nitrogen/generated/android/c++/JAudioRouteKind.hpp +91 -0
  807. package/nitrogen/generated/android/c++/JBrowseItem.hpp +78 -0
  808. package/nitrogen/generated/android/c++/JBrowseSection.hpp +97 -0
  809. package/nitrogen/generated/android/c++/JBrowseSnapshot.hpp +87 -0
  810. package/nitrogen/generated/android/c++/JBufferState.hpp +64 -0
  811. package/nitrogen/generated/android/c++/JCacheStatus.hpp +92 -0
  812. package/nitrogen/generated/android/c++/JCastConfigureOptions.hpp +62 -0
  813. package/nitrogen/generated/android/c++/JCastConnectOptions.hpp +57 -0
  814. package/nitrogen/generated/android/c++/JCastDeviceKind.hpp +70 -0
  815. package/nitrogen/generated/android/c++/JCastDiscoveryState.hpp +57 -0
  816. package/nitrogen/generated/android/c++/JCastLocalNetworkPermission.hpp +61 -0
  817. package/nitrogen/generated/android/c++/JCastLocalNetworkPermissionEvent.hpp +58 -0
  818. package/nitrogen/generated/android/c++/JCastProtocol.hpp +67 -0
  819. package/nitrogen/generated/android/c++/JCastReceiver.hpp +89 -0
  820. package/nitrogen/generated/android/c++/JCastReceiverCapabilities.hpp +66 -0
  821. package/nitrogen/generated/android/c++/JCastRoute.hpp +85 -0
  822. package/nitrogen/generated/android/c++/JCastSessionDiedEvent.hpp +63 -0
  823. package/nitrogen/generated/android/c++/JCastSessionEndReason.hpp +76 -0
  824. package/nitrogen/generated/android/c++/JCastStreamingModel.hpp +58 -0
  825. package/nitrogen/generated/android/c++/JEqualizerBand.hpp +61 -0
  826. package/nitrogen/generated/android/c++/JEqualizerPreset.hpp +76 -0
  827. package/nitrogen/generated/android/c++/JEvictionPolicy.hpp +58 -0
  828. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.hpp +146 -0
  829. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.hpp +161 -0
  830. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.hpp +147 -0
  831. package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
  832. package/nitrogen/generated/android/c++/JFunc_void_AudioRoute.hpp +80 -0
  833. package/nitrogen/generated/android/c++/JFunc_void_BufferState.hpp +77 -0
  834. package/nitrogen/generated/android/c++/JFunc_void_CacheStatus.hpp +79 -0
  835. package/nitrogen/generated/android/c++/JFunc_void_CastDiscoveryState.hpp +77 -0
  836. package/nitrogen/generated/android/c++/JFunc_void_CastLocalNetworkPermissionEvent.hpp +79 -0
  837. package/nitrogen/generated/android/c++/JFunc_void_CastRoute.hpp +86 -0
  838. package/nitrogen/generated/android/c++/JFunc_void_CastSessionDiedEvent.hpp +80 -0
  839. package/nitrogen/generated/android/c++/JFunc_void_PlaybackError.hpp +80 -0
  840. package/nitrogen/generated/android/c++/JFunc_void_PlayerProgress.hpp +77 -0
  841. package/nitrogen/generated/android/c++/JFunc_void_PlayerState_StateChangeReason.hpp +79 -0
  842. package/nitrogen/generated/android/c++/JFunc_void_ServiceReadyReason.hpp +77 -0
  843. package/nitrogen/generated/android/c++/JFunc_void_SkipCapability.hpp +77 -0
  844. package/nitrogen/generated/android/c++/JFunc_void_SleepTimerState.hpp +78 -0
  845. package/nitrogen/generated/android/c++/JFunc_void_VisualizerErrorReason.hpp +77 -0
  846. package/nitrogen/generated/android/c++/JFunc_void_VisualizerFrame.hpp +80 -0
  847. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  848. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  849. package/nitrogen/generated/android/c++/JFunc_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.hpp +82 -0
  850. package/nitrogen/generated/android/c++/JFunc_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.hpp +93 -0
  851. package/nitrogen/generated/android/c++/JFunc_void_std__vector_CastReceiver_.hpp +105 -0
  852. package/nitrogen/generated/android/c++/JFunc_void_std__vector_EqualizerBand_.hpp +96 -0
  853. package/nitrogen/generated/android/c++/JHybridCastManagerSpec.cpp +388 -0
  854. package/nitrogen/generated/android/c++/JHybridCastManagerSpec.hpp +84 -0
  855. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +233 -0
  856. package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +74 -0
  857. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +1052 -0
  858. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +130 -0
  859. package/nitrogen/generated/android/c++/JHybridVisualizerSpec.cpp +84 -0
  860. package/nitrogen/generated/android/c++/JHybridVisualizerSpec.hpp +64 -0
  861. package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +61 -0
  862. package/nitrogen/generated/android/c++/JMediaSearchOrigin.hpp +58 -0
  863. package/nitrogen/generated/android/c++/JMediaSearchQueueLocation.hpp +61 -0
  864. package/nitrogen/generated/android/c++/JMediaSearchReference.hpp +58 -0
  865. package/nitrogen/generated/android/c++/JMediaSearchReleaseDateRange.hpp +61 -0
  866. package/nitrogen/generated/android/c++/JMediaSearchRepeatMode.hpp +61 -0
  867. package/nitrogen/generated/android/c++/JMediaSearchRequest.hpp +126 -0
  868. package/nitrogen/generated/android/c++/JMediaSearchType.hpp +67 -0
  869. package/nitrogen/generated/android/c++/JNowPlayingFormat.hpp +123 -0
  870. package/nitrogen/generated/android/c++/JNowPlayingFormatAndroidExtras.hpp +71 -0
  871. package/nitrogen/generated/android/c++/JNowPlayingFormatIosExtras.hpp +61 -0
  872. package/nitrogen/generated/android/c++/JPitchCorrectionMode.hpp +61 -0
  873. package/nitrogen/generated/android/c++/JPlaybackError.hpp +87 -0
  874. package/nitrogen/generated/android/c++/JPlaybackErrorCode.hpp +88 -0
  875. package/nitrogen/generated/android/c++/JPlaybackMode.hpp +63 -0
  876. package/nitrogen/generated/android/c++/JPlaybackModeKind.hpp +58 -0
  877. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +126 -0
  878. package/nitrogen/generated/android/c++/JPlayerProgress.hpp +65 -0
  879. package/nitrogen/generated/android/c++/JPlayerState.hpp +73 -0
  880. package/nitrogen/generated/android/c++/JRemoteControlOptions.hpp +66 -0
  881. package/nitrogen/generated/android/c++/JRemoteSkipMode.hpp +58 -0
  882. package/nitrogen/generated/android/c++/JRepeatMode.hpp +61 -0
  883. package/nitrogen/generated/android/c++/JReplayGainMode.hpp +61 -0
  884. package/nitrogen/generated/android/c++/JSectionIcon.hpp +91 -0
  885. package/nitrogen/generated/android/c++/JServiceReadyReason.hpp +58 -0
  886. package/nitrogen/generated/android/c++/JShuffleResult.hpp +88 -0
  887. package/nitrogen/generated/android/c++/JSkipCapability.hpp +61 -0
  888. package/nitrogen/generated/android/c++/JSleepTimerState.hpp +69 -0
  889. package/nitrogen/generated/android/c++/JStateChangeReason.hpp +73 -0
  890. package/nitrogen/generated/android/c++/JTrackChangeReason.hpp +73 -0
  891. package/nitrogen/generated/android/c++/JTrackItem.hpp +100 -0
  892. package/nitrogen/generated/android/c++/JTrackSource.hpp +61 -0
  893. package/nitrogen/generated/android/c++/JVariant_NullType_Double.cpp +26 -0
  894. package/nitrogen/generated/android/c++/JVariant_NullType_Double.hpp +69 -0
  895. package/nitrogen/generated/android/c++/JVariant_NullType_NowPlayingFormat.cpp +26 -0
  896. package/nitrogen/generated/android/c++/JVariant_NullType_NowPlayingFormat.hpp +83 -0
  897. package/nitrogen/generated/android/c++/JVariant_NullType_String.cpp +26 -0
  898. package/nitrogen/generated/android/c++/JVariant_NullType_String.hpp +70 -0
  899. package/nitrogen/generated/android/c++/JVisualizerConfig.hpp +65 -0
  900. package/nitrogen/generated/android/c++/JVisualizerErrorReason.hpp +64 -0
  901. package/nitrogen/generated/android/c++/JVisualizerFrame.hpp +67 -0
  902. package/nitrogen/generated/android/c++/JVoiceVocabulary.hpp +98 -0
  903. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioCategoryOptions.kt +61 -0
  904. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioCodec.kt +28 -0
  905. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioContainer.kt +29 -0
  906. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioContentType.kt +23 -0
  907. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioRoute.kt +71 -0
  908. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioRouteKind.kt +34 -0
  909. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseItem.kt +76 -0
  910. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseSection.kt +71 -0
  911. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseSnapshot.kt +56 -0
  912. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BufferState.kt +25 -0
  913. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CacheStatus.kt +71 -0
  914. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastConfigureOptions.kt +56 -0
  915. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastConnectOptions.kt +51 -0
  916. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastDeviceKind.kt +27 -0
  917. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastDiscoveryState.kt +51 -0
  918. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastLocalNetworkPermission.kt +24 -0
  919. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastLocalNetworkPermissionEvent.kt +51 -0
  920. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastProtocol.kt +26 -0
  921. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastReceiver.kt +81 -0
  922. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastReceiverCapabilities.kt +61 -0
  923. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastRoute.kt +76 -0
  924. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastSessionDiedEvent.kt +56 -0
  925. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastSessionEndReason.kt +29 -0
  926. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastStreamingModel.kt +23 -0
  927. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EqualizerBand.kt +56 -0
  928. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EqualizerPreset.kt +61 -0
  929. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EvictionPolicy.kt +23 -0
  930. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.kt +80 -0
  931. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.kt +80 -0
  932. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.kt +80 -0
  933. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +80 -0
  934. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +80 -0
  935. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +80 -0
  936. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +80 -0
  937. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +80 -0
  938. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +80 -0
  939. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +80 -0
  940. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +80 -0
  941. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +80 -0
  942. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +80 -0
  943. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +80 -0
  944. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +80 -0
  945. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +80 -0
  946. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +80 -0
  947. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +80 -0
  948. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +80 -0
  949. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +80 -0
  950. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +80 -0
  951. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +80 -0
  952. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +80 -0
  953. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +80 -0
  954. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +80 -0
  955. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +169 -0
  956. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +109 -0
  957. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +419 -0
  958. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +63 -0
  959. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +56 -0
  960. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchOrigin.kt +23 -0
  961. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchQueueLocation.kt +24 -0
  962. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchReference.kt +23 -0
  963. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchReleaseDateRange.kt +56 -0
  964. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchRepeatMode.kt +24 -0
  965. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchRequest.kt +121 -0
  966. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchType.kt +26 -0
  967. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormat.kt +116 -0
  968. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormatAndroidExtras.kt +61 -0
  969. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormatIosExtras.kt +51 -0
  970. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PitchCorrectionMode.kt +24 -0
  971. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackError.kt +86 -0
  972. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackErrorCode.kt +33 -0
  973. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackMode.kt +56 -0
  974. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackModeKind.kt +23 -0
  975. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +111 -0
  976. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerProgress.kt +61 -0
  977. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerState.kt +28 -0
  978. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RemoteControlOptions.kt +61 -0
  979. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RemoteSkipMode.kt +23 -0
  980. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RepeatMode.kt +24 -0
  981. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ReplayGainMode.kt +24 -0
  982. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SectionIcon.kt +34 -0
  983. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ServiceReadyReason.kt +23 -0
  984. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ShuffleResult.kt +61 -0
  985. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SkipCapability.kt +56 -0
  986. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SleepTimerState.kt +66 -0
  987. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/StateChangeReason.kt +28 -0
  988. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackChangeReason.kt +28 -0
  989. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +86 -0
  990. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackSource.kt +24 -0
  991. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_Double.kt +62 -0
  992. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_NowPlayingFormat.kt +62 -0
  993. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_String.kt +62 -0
  994. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerConfig.kt +61 -0
  995. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerErrorReason.kt +25 -0
  996. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerFrame.kt +61 -0
  997. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VoiceVocabulary.kt +56 -0
  998. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/queueplayerOnLoad.kt +35 -0
  999. package/nitrogen/generated/android/queueplayer+autolinking.cmake +90 -0
  1000. package/nitrogen/generated/android/queueplayer+autolinking.gradle +27 -0
  1001. package/nitrogen/generated/android/queueplayerOnLoad.cpp +152 -0
  1002. package/nitrogen/generated/android/queueplayerOnLoad.hpp +34 -0
  1003. package/nitrogen/generated/ios/QueuePlayer+autolinking.rb +62 -0
  1004. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +343 -0
  1005. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +1831 -0
  1006. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +256 -0
  1007. package/nitrogen/generated/ios/QueuePlayerAutolinking.mm +57 -0
  1008. package/nitrogen/generated/ios/QueuePlayerAutolinking.swift +62 -0
  1009. package/nitrogen/generated/ios/c++/HybridCastManagerSpecSwift.cpp +11 -0
  1010. package/nitrogen/generated/ios/c++/HybridCastManagerSpecSwift.hpp +298 -0
  1011. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.cpp +11 -0
  1012. package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +178 -0
  1013. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.cpp +11 -0
  1014. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +749 -0
  1015. package/nitrogen/generated/ios/c++/HybridVisualizerSpecSwift.cpp +11 -0
  1016. package/nitrogen/generated/ios/c++/HybridVisualizerSpecSwift.hpp +103 -0
  1017. package/nitrogen/generated/ios/swift/AudioCategoryOptions.swift +78 -0
  1018. package/nitrogen/generated/ios/swift/AudioCodec.swift +60 -0
  1019. package/nitrogen/generated/ios/swift/AudioContainer.swift +64 -0
  1020. package/nitrogen/generated/ios/swift/AudioContentType.swift +40 -0
  1021. package/nitrogen/generated/ios/swift/AudioRoute.swift +49 -0
  1022. package/nitrogen/generated/ios/swift/AudioRouteKind.swift +84 -0
  1023. package/nitrogen/generated/ios/swift/BrowseItem.swift +93 -0
  1024. package/nitrogen/generated/ios/swift/BrowseSection.swift +74 -0
  1025. package/nitrogen/generated/ios/swift/BrowseSnapshot.swift +40 -0
  1026. package/nitrogen/generated/ios/swift/BufferState.swift +48 -0
  1027. package/nitrogen/generated/ios/swift/CacheStatus.swift +55 -0
  1028. package/nitrogen/generated/ios/swift/CastConfigureOptions.swift +47 -0
  1029. package/nitrogen/generated/ios/swift/CastConnectOptions.swift +29 -0
  1030. package/nitrogen/generated/ios/swift/CastDeviceKind.swift +56 -0
  1031. package/nitrogen/generated/ios/swift/CastDiscoveryState.swift +29 -0
  1032. package/nitrogen/generated/ios/swift/CastLocalNetworkPermission.swift +44 -0
  1033. package/nitrogen/generated/ios/swift/CastLocalNetworkPermissionEvent.swift +29 -0
  1034. package/nitrogen/generated/ios/swift/CastProtocol.swift +52 -0
  1035. package/nitrogen/generated/ios/swift/CastReceiver.swift +59 -0
  1036. package/nitrogen/generated/ios/swift/CastReceiverCapabilities.swift +39 -0
  1037. package/nitrogen/generated/ios/swift/CastRoute.swift +54 -0
  1038. package/nitrogen/generated/ios/swift/CastSessionDiedEvent.swift +34 -0
  1039. package/nitrogen/generated/ios/swift/CastSessionEndReason.swift +64 -0
  1040. package/nitrogen/generated/ios/swift/CastStreamingModel.swift +40 -0
  1041. package/nitrogen/generated/ios/swift/EqualizerBand.swift +34 -0
  1042. package/nitrogen/generated/ios/swift/EqualizerPreset.swift +45 -0
  1043. package/nitrogen/generated/ios/swift/EvictionPolicy.swift +40 -0
  1044. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.swift +67 -0
  1045. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.swift +67 -0
  1046. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.swift +67 -0
  1047. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  1048. package/nitrogen/generated/ios/swift/Func_void_AudioRoute.swift +46 -0
  1049. package/nitrogen/generated/ios/swift/Func_void_BufferState.swift +46 -0
  1050. package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +46 -0
  1051. package/nitrogen/generated/ios/swift/Func_void_CastDiscoveryState.swift +46 -0
  1052. package/nitrogen/generated/ios/swift/Func_void_CastLocalNetworkPermission.swift +46 -0
  1053. package/nitrogen/generated/ios/swift/Func_void_CastLocalNetworkPermissionEvent.swift +46 -0
  1054. package/nitrogen/generated/ios/swift/Func_void_CastRoute.swift +46 -0
  1055. package/nitrogen/generated/ios/swift/Func_void_CastSessionDiedEvent.swift +46 -0
  1056. package/nitrogen/generated/ios/swift/Func_void_PlaybackError.swift +46 -0
  1057. package/nitrogen/generated/ios/swift/Func_void_PlayerProgress.swift +46 -0
  1058. package/nitrogen/generated/ios/swift/Func_void_PlayerState_StateChangeReason.swift +46 -0
  1059. package/nitrogen/generated/ios/swift/Func_void_ServiceReadyReason.swift +46 -0
  1060. package/nitrogen/generated/ios/swift/Func_void_ShuffleResult.swift +46 -0
  1061. package/nitrogen/generated/ios/swift/Func_void_SkipCapability.swift +46 -0
  1062. package/nitrogen/generated/ios/swift/Func_void_SleepTimerState.swift +46 -0
  1063. package/nitrogen/generated/ios/swift/Func_void_VisualizerErrorReason.swift +46 -0
  1064. package/nitrogen/generated/ios/swift/Func_void_VisualizerFrame.swift +46 -0
  1065. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  1066. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  1067. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  1068. package/nitrogen/generated/ios/swift/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.swift +53 -0
  1069. package/nitrogen/generated/ios/swift/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.swift +65 -0
  1070. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__vector_BrowseItem___.swift +66 -0
  1071. package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__vector_TrackItem___.swift +66 -0
  1072. package/nitrogen/generated/ios/swift/Func_void_std__vector_BrowseItem_.swift +46 -0
  1073. package/nitrogen/generated/ios/swift/Func_void_std__vector_CastReceiver_.swift +46 -0
  1074. package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +46 -0
  1075. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem_.swift +46 -0
  1076. package/nitrogen/generated/ios/swift/HybridCastManagerSpec.swift +76 -0
  1077. package/nitrogen/generated/ios/swift/HybridCastManagerSpec_cxx.swift +519 -0
  1078. package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +66 -0
  1079. package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +353 -0
  1080. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +122 -0
  1081. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +1430 -0
  1082. package/nitrogen/generated/ios/swift/HybridVisualizerSpec.swift +56 -0
  1083. package/nitrogen/generated/ios/swift/HybridVisualizerSpec_cxx.swift +163 -0
  1084. package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +34 -0
  1085. package/nitrogen/generated/ios/swift/MediaSearchOrigin.swift +40 -0
  1086. package/nitrogen/generated/ios/swift/MediaSearchQueueLocation.swift +44 -0
  1087. package/nitrogen/generated/ios/swift/MediaSearchReference.swift +40 -0
  1088. package/nitrogen/generated/ios/swift/MediaSearchReleaseDateRange.swift +60 -0
  1089. package/nitrogen/generated/ios/swift/MediaSearchRepeatMode.swift +44 -0
  1090. package/nitrogen/generated/ios/swift/MediaSearchRequest.swift +239 -0
  1091. package/nitrogen/generated/ios/swift/MediaSearchType.swift +52 -0
  1092. package/nitrogen/generated/ios/swift/NowPlayingFormat.swift +394 -0
  1093. package/nitrogen/generated/ios/swift/NowPlayingFormatAndroidExtras.swift +135 -0
  1094. package/nitrogen/generated/ios/swift/NowPlayingFormatIosExtras.swift +61 -0
  1095. package/nitrogen/generated/ios/swift/PitchCorrectionMode.swift +44 -0
  1096. package/nitrogen/generated/ios/swift/PlaybackError.swift +64 -0
  1097. package/nitrogen/generated/ios/swift/PlaybackErrorCode.swift +80 -0
  1098. package/nitrogen/generated/ios/swift/PlaybackMode.swift +47 -0
  1099. package/nitrogen/generated/ios/swift/PlaybackModeKind.swift +40 -0
  1100. package/nitrogen/generated/ios/swift/PlayerConfig.swift +251 -0
  1101. package/nitrogen/generated/ios/swift/PlayerProgress.swift +39 -0
  1102. package/nitrogen/generated/ios/swift/PlayerState.swift +60 -0
  1103. package/nitrogen/generated/ios/swift/RemoteControlOptions.swift +39 -0
  1104. package/nitrogen/generated/ios/swift/RemoteSkipMode.swift +40 -0
  1105. package/nitrogen/generated/ios/swift/RepeatMode.swift +44 -0
  1106. package/nitrogen/generated/ios/swift/ReplayGainMode.swift +44 -0
  1107. package/nitrogen/generated/ios/swift/SectionIcon.swift +84 -0
  1108. package/nitrogen/generated/ios/swift/ServiceReadyReason.swift +40 -0
  1109. package/nitrogen/generated/ios/swift/ShuffleResult.swift +51 -0
  1110. package/nitrogen/generated/ios/swift/SkipCapability.swift +34 -0
  1111. package/nitrogen/generated/ios/swift/SleepTimerState.swift +70 -0
  1112. package/nitrogen/generated/ios/swift/StateChangeReason.swift +60 -0
  1113. package/nitrogen/generated/ios/swift/TrackChangeReason.swift +60 -0
  1114. package/nitrogen/generated/ios/swift/TrackItem.swift +169 -0
  1115. package/nitrogen/generated/ios/swift/TrackSource.swift +44 -0
  1116. package/nitrogen/generated/ios/swift/Variant_NullType_Double.swift +30 -0
  1117. package/nitrogen/generated/ios/swift/Variant_NullType_NowPlayingFormat.swift +30 -0
  1118. package/nitrogen/generated/ios/swift/Variant_NullType_String.swift +30 -0
  1119. package/nitrogen/generated/ios/swift/VisualizerConfig.swift +52 -0
  1120. package/nitrogen/generated/ios/swift/VisualizerErrorReason.swift +48 -0
  1121. package/nitrogen/generated/ios/swift/VisualizerFrame.swift +52 -0
  1122. package/nitrogen/generated/ios/swift/VoiceVocabulary.swift +46 -0
  1123. package/nitrogen/generated/shared/c++/AudioCategoryOptions.hpp +91 -0
  1124. package/nitrogen/generated/shared/c++/AudioCodec.hpp +96 -0
  1125. package/nitrogen/generated/shared/c++/AudioContainer.hpp +100 -0
  1126. package/nitrogen/generated/shared/c++/AudioContentType.hpp +76 -0
  1127. package/nitrogen/generated/shared/c++/AudioRoute.hpp +101 -0
  1128. package/nitrogen/generated/shared/c++/AudioRouteKind.hpp +120 -0
  1129. package/nitrogen/generated/shared/c++/BrowseItem.hpp +104 -0
  1130. package/nitrogen/generated/shared/c++/BrowseSection.hpp +106 -0
  1131. package/nitrogen/generated/shared/c++/BrowseSnapshot.hpp +90 -0
  1132. package/nitrogen/generated/shared/c++/BufferState.hpp +84 -0
  1133. package/nitrogen/generated/shared/c++/CacheStatus.hpp +100 -0
  1134. package/nitrogen/generated/shared/c++/CastConfigureOptions.hpp +88 -0
  1135. package/nitrogen/generated/shared/c++/CastConnectOptions.hpp +83 -0
  1136. package/nitrogen/generated/shared/c++/CastDeviceKind.hpp +92 -0
  1137. package/nitrogen/generated/shared/c++/CastDiscoveryState.hpp +83 -0
  1138. package/nitrogen/generated/shared/c++/CastLocalNetworkPermission.hpp +80 -0
  1139. package/nitrogen/generated/shared/c++/CastLocalNetworkPermissionEvent.hpp +84 -0
  1140. package/nitrogen/generated/shared/c++/CastProtocol.hpp +88 -0
  1141. package/nitrogen/generated/shared/c++/CastReceiver.hpp +115 -0
  1142. package/nitrogen/generated/shared/c++/CastReceiverCapabilities.hpp +92 -0
  1143. package/nitrogen/generated/shared/c++/CastRoute.hpp +111 -0
  1144. package/nitrogen/generated/shared/c++/CastSessionDiedEvent.hpp +89 -0
  1145. package/nitrogen/generated/shared/c++/CastSessionEndReason.hpp +100 -0
  1146. package/nitrogen/generated/shared/c++/CastStreamingModel.hpp +76 -0
  1147. package/nitrogen/generated/shared/c++/EqualizerBand.hpp +87 -0
  1148. package/nitrogen/generated/shared/c++/EqualizerPreset.hpp +92 -0
  1149. package/nitrogen/generated/shared/c++/EvictionPolicy.hpp +76 -0
  1150. package/nitrogen/generated/shared/c++/HybridCastManagerSpec.cpp +42 -0
  1151. package/nitrogen/generated/shared/c++/HybridCastManagerSpec.hpp +115 -0
  1152. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.cpp +32 -0
  1153. package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +81 -0
  1154. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +88 -0
  1155. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +209 -0
  1156. package/nitrogen/generated/shared/c++/HybridVisualizerSpec.cpp +22 -0
  1157. package/nitrogen/generated/shared/c++/HybridVisualizerSpec.hpp +71 -0
  1158. package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +87 -0
  1159. package/nitrogen/generated/shared/c++/MediaSearchOrigin.hpp +76 -0
  1160. package/nitrogen/generated/shared/c++/MediaSearchQueueLocation.hpp +80 -0
  1161. package/nitrogen/generated/shared/c++/MediaSearchReference.hpp +76 -0
  1162. package/nitrogen/generated/shared/c++/MediaSearchReleaseDateRange.hpp +87 -0
  1163. package/nitrogen/generated/shared/c++/MediaSearchRepeatMode.hpp +80 -0
  1164. package/nitrogen/generated/shared/c++/MediaSearchRequest.hpp +157 -0
  1165. package/nitrogen/generated/shared/c++/MediaSearchType.hpp +88 -0
  1166. package/nitrogen/generated/shared/c++/NowPlayingFormat.hpp +149 -0
  1167. package/nitrogen/generated/shared/c++/NowPlayingFormatAndroidExtras.hpp +94 -0
  1168. package/nitrogen/generated/shared/c++/NowPlayingFormatIosExtras.hpp +85 -0
  1169. package/nitrogen/generated/shared/c++/PitchCorrectionMode.hpp +80 -0
  1170. package/nitrogen/generated/shared/c++/PlaybackError.hpp +113 -0
  1171. package/nitrogen/generated/shared/c++/PlaybackErrorCode.hpp +116 -0
  1172. package/nitrogen/generated/shared/c++/PlaybackMode.hpp +89 -0
  1173. package/nitrogen/generated/shared/c++/PlaybackModeKind.hpp +76 -0
  1174. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +141 -0
  1175. package/nitrogen/generated/shared/c++/PlayerProgress.hpp +91 -0
  1176. package/nitrogen/generated/shared/c++/PlayerState.hpp +96 -0
  1177. package/nitrogen/generated/shared/c++/RemoteControlOptions.hpp +92 -0
  1178. package/nitrogen/generated/shared/c++/RemoteSkipMode.hpp +76 -0
  1179. package/nitrogen/generated/shared/c++/RepeatMode.hpp +80 -0
  1180. package/nitrogen/generated/shared/c++/ReplayGainMode.hpp +80 -0
  1181. package/nitrogen/generated/shared/c++/SectionIcon.hpp +120 -0
  1182. package/nitrogen/generated/shared/c++/ServiceReadyReason.hpp +76 -0
  1183. package/nitrogen/generated/shared/c++/ShuffleResult.hpp +94 -0
  1184. package/nitrogen/generated/shared/c++/SkipCapability.hpp +87 -0
  1185. package/nitrogen/generated/shared/c++/SleepTimerState.hpp +95 -0
  1186. package/nitrogen/generated/shared/c++/StateChangeReason.hpp +96 -0
  1187. package/nitrogen/generated/shared/c++/TrackChangeReason.hpp +96 -0
  1188. package/nitrogen/generated/shared/c++/TrackItem.hpp +113 -0
  1189. package/nitrogen/generated/shared/c++/TrackSource.hpp +80 -0
  1190. package/nitrogen/generated/shared/c++/VisualizerConfig.hpp +91 -0
  1191. package/nitrogen/generated/shared/c++/VisualizerErrorReason.hpp +84 -0
  1192. package/nitrogen/generated/shared/c++/VisualizerFrame.hpp +92 -0
  1193. package/nitrogen/generated/shared/c++/VoiceVocabulary.hpp +88 -0
  1194. package/package.json +167 -0
  1195. package/src/CastManager.nitro.ts +206 -0
  1196. package/src/Equalizer.nitro.ts +37 -0
  1197. package/src/TrackPlayer.nitro.ts +667 -0
  1198. package/src/Visualizer.nitro.ts +48 -0
  1199. package/src/cast/index.ts +224 -0
  1200. package/src/clients.ts +50 -0
  1201. package/src/equalizer.ts +86 -0
  1202. package/src/hooks/index.ts +33 -0
  1203. package/src/hooks/useActiveTrack.ts +55 -0
  1204. package/src/hooks/useAudioRoute.ts +69 -0
  1205. package/src/hooks/useBufferState.ts +35 -0
  1206. package/src/hooks/useCanSkip.ts +92 -0
  1207. package/src/hooks/useCast.ts +121 -0
  1208. package/src/hooks/useEqualizer.ts +111 -0
  1209. package/src/hooks/useFullyBuffered.ts +32 -0
  1210. package/src/hooks/useLookaheadCache.ts +54 -0
  1211. package/src/hooks/useNowPlayingFormat.ts +43 -0
  1212. package/src/hooks/usePlaybackState.ts +34 -0
  1213. package/src/hooks/useProgress.ts +36 -0
  1214. package/src/hooks/useSleepTimer.ts +63 -0
  1215. package/src/hooks/useVisualizerState.ts +121 -0
  1216. package/src/index.ts +125 -0
  1217. package/src/playbackService.ts +304 -0
  1218. package/src/types.ts +1387 -0
@@ -0,0 +1,4766 @@
1
+ import AVFoundation
2
+ import NitroModules
3
+ import UIKit
4
+
5
+ /// iOS TrackPlayer — Nitro HybridObject holding the AVQueuePlayer
6
+ /// instance + runtime config. Lifecycle (configure / destroy),
7
+ /// transport, queue mutation, skip, gapless, events, audio session,
8
+ /// and lookahead-cache methods all live here.
9
+ class TrackPlayer: HybridTrackPlayerSpec {
10
+ // MARK: - Stored state
11
+
12
+ /// Periodic-time-observer firing rate for the progress event. Two
13
+ /// ticks per second matches the JS-side onProgress contract and
14
+ /// keeps backgrounded playback updates flowing without burning
15
+ /// power on a faster cadence.
16
+ private static let progressIntervalSeconds: Double = 0.5
17
+
18
+ /// Tolerance for "the loaded range has reached duration" — rounding can leave
19
+ /// a sub-second gap when a track is fully downloaded.
20
+ private static let fullyBufferedEpsilonSeconds: Double = 1.0
21
+
22
+ /// Sleep-timer tick cadence. 0.5s keeps the 10s linear fade smooth while
23
+ /// staying cheap enough to run whether playback is playing or paused.
24
+ private static let sleepTimerTickInterval: TimeInterval = 0.5
25
+
26
+ /// The underlying AVQueuePlayer. `nil` before configure(), torn down
27
+ /// on destroy(). AVQueuePlayer specifically (not AVPlayer) because
28
+ /// it owns queue management + gapless transitions — see
29
+ /// NOTES.md §5 + plan §3.2.
30
+ internal var player: AVQueuePlayer?
31
+ /// Strategy-pattern engine wrapping the active player. Owns
32
+ /// the underlying `AVQueuePlayer`. JS-facing transport (play,
33
+ /// pause, stop) and config (setVolume, setPlaybackSpeed) route
34
+ /// through this surface. Paths intentionally still on
35
+ /// `self.player?` in this file: queue mutation (`setQueue` +
36
+ /// `addToQueue` + `removeFromQueue` + `moveInQueue` +
37
+ /// `clearQueue` + `shuffleQueue` queue-rebuild via
38
+ /// `AVQueueBuilder` + `replaceCurrentItem` / `removeAllItems`),
39
+ /// skip (`skipToIndex` / `skipToNext` / `skipToPrevious` use
40
+ /// `fullRebuildPlayerQueue` because AVQueuePlayer is forward-
41
+ /// only and engine `seek(toIndex:)` requires the caller to
42
+ /// rebuild backward), single-item seek (`seekTo(position:)`),
43
+ /// error-recovery `pause()`, observer wiring (`installGaplessObservers`,
44
+ /// `installEventObservers`), audio session activation, NowPlaying /
45
+ /// RemoteCommands integration, and read paths (`currentTime`,
46
+ /// `timeControlStatus`, etc. — engine doesn't expose readable
47
+ /// scalars for these). Crossfade engine fill-in lifts the
48
+ /// queue/skip surface onto the engine.
49
+ internal var engine: PlaybackEngine?
50
+
51
+ /// Authoritative queue state — two parallel arrays (`tracks` +
52
+ /// `queueItemIds`) owned by `QueueState` so the type system
53
+ /// prevents either field from being mutated without the other.
54
+ /// AVQueuePlayer consumes items forward-only; skip-backward
55
+ /// rebuilds from `queueState.tracks`. `queueItemIds` is the
56
+ /// lib-generated identity per position, stamped onto each
57
+ /// AVPlayerItem so `matchTrackIndex` can resolve currentItem back
58
+ /// to an index. Queue-position identity is never derived from
59
+ /// `TrackItem` fields — only `track.url` is required + reliable
60
+ /// on consumer-supplied data.
61
+ internal var queueState = QueueState()
62
+
63
+ /// Read-only view of the queue's TrackItem array. Mutations go
64
+ /// through `queueState`'s API.
65
+ internal var tracks: [TrackItem] { queueState.tracks }
66
+
67
+ /// Read-only view of the parallel queue-item IDs. Mutations go
68
+ /// through `queueState`'s API.
69
+ internal var queueItemIds: [String] { queueState.queueItemIds }
70
+
71
+ /// Belt-and-braces: verify the parallel-array invariant before
72
+ /// any function that consumes both arrays. `QueueState` enforces
73
+ /// this at every mutation site, but a direct read of either array
74
+ /// outside `queueState`'s API can drift if a future contributor
75
+ /// bypasses the type. Catches the desync earlier than the
76
+ /// construction-time `precondition` in `AVQueueBuilder`.
77
+ /// Debug-only — the precondition vanishes in Release builds where
78
+ /// `AVQueueBuilder` already fails soft (returns `[]` + NSLog) on
79
+ /// the same desync.
80
+ private func assertQueueIdsInvariant(_ caller: StaticString) {
81
+ #if DEBUG
82
+ precondition(
83
+ self.tracks.count == self.queueItemIds.count,
84
+ "QueueState parallel-array invariant violated at \(caller): tracks=\(self.tracks.count), queueItemIds=\(self.queueItemIds.count)"
85
+ )
86
+ #endif
87
+ }
88
+
89
+ /// Index into `tracks` of the currently-playing item. -1 when empty
90
+ /// or before the first play. Surfaced to JS via getCurrentTrackIndex.
91
+ internal var currentTrackIndex: Int = -1
92
+
93
+ /// Most recent PlayerConfig passed in via configure(). Defaults are
94
+ /// empty — no custom headers, default UA. Used by AVQueueBuilder
95
+ /// when creating new AVURLAssets.
96
+ internal var config: PlayerConfig =
97
+ PlayerConfig(
98
+ httpHeaders: nil,
99
+ userAgent: nil,
100
+ autoRetries: nil,
101
+ retryBackoffMs: nil,
102
+ networkTimeoutMs: nil,
103
+ audioContentType: nil,
104
+ audioCategoryOptions: nil,
105
+ visualizationEnabled: nil,
106
+ clampSeekToBuffered: nil,
107
+ lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil,
108
+ progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil
109
+ )
110
+
111
+ internal var repeatModeState: RepeatMode = .off
112
+
113
+ /// Canonical volume + playback speed, independent of the active
114
+ /// engine. Setters update these and forward to the engine; getters
115
+ /// read these (not `self.player`, which is nil under crossfade); the
116
+ /// engine swap re-applies them so a gapless↔crossfade swap preserves
117
+ /// the user's settings.
118
+ internal var volumeState: Float = 1.0
119
+ internal var playbackSpeedState: Float = 1.0
120
+
121
+ /// Lock-screen / control-center remote-control config. Applied to
122
+ /// `RemoteCommands` on set + re-applied on `configure`. Interval seek is
123
+ /// performed natively from the `onCommand` handler.
124
+ private var remoteSkipMode: RemoteSkipMode = .track
125
+ private var remoteForwardInterval: Double = 15
126
+ private var remoteBackwardInterval: Double = 15
127
+
128
+ /// Canonical pitch-correction mode, independent of the active engine.
129
+ /// Re-applied to the engine at configure + engine swap so it survives
130
+ /// a destroy→configure cycle and a gapless↔crossfade swap. Default
131
+ /// `voice` (pitch preserved, cheapest correct option).
132
+ internal var pitchCorrectionModeState: PitchCorrectionMode = .voice
133
+
134
+ /// AVAudioSession wrapper — handles activate/deactivate,
135
+ /// interruption (phone calls, Siri), and route change (headphones
136
+ /// unplugged) notifications. Wired in configure, torn down on
137
+ /// destroy. Interruption handlers pause + emit state change with
138
+ /// reason `.interruption`; resume on `.endedShouldResume`.
139
+ /// Route-change pauses on `.oldDeviceUnavailable` per Apple HIG.
140
+ private let audioSession = AudioSession()
141
+
142
+ /// Owns the `MPNowPlayingInfoCenter` dictionary that drives the
143
+ /// lock-screen / control-center / CarPlay surfaces. Wired in
144
+ /// `configure` (snapshot provider + 10s periodic timer), torn down
145
+ /// in `destroy`.
146
+ private let nowPlayingInfo = NowPlayingInfo()
147
+
148
+ /// Owns the `MPRemoteCommandCenter` handler registrations that
149
+ /// receive lock-screen / control-center / CarPlay / hardware
150
+ /// transport events. Wired in `configure`, uninstalled in `destroy`.
151
+ /// Each handler stamps `pendingStateChangeReason = .system` before
152
+ /// routing into the public transport methods so the resulting state-
153
+ /// change emit carries `.system`, distinguishing remote-command
154
+ /// origin from in-app `.user` taps.
155
+ private let remoteCommands = RemoteCommands()
156
+
157
+ /// Resolves `track.artworkUrl` to a UIImage for the lock-screen
158
+ /// surface. The placeholder is the synchronous floor (already
159
+ /// surfaced by `nowPlayingInfo.refreshAll`); `applyArtwork` swaps
160
+ /// in the resolved bitmap when the network fetch lands. Single
161
+ /// in-flight request — cancelled on track-change.
162
+ private let artworkResolver = ArtworkResolver()
163
+
164
+ /// Lookahead cache + prefetcher. Built in `configure`, torn down in
165
+ /// `destroy` (prefetcher first so its in-flight task can observe
166
+ /// cancellation cleanly before the cache's URLSession is
167
+ /// invalidated). Default 100 MB budget + 3-track lookahead window;
168
+ /// `setLookaheadCache` lets consumers override at runtime.
169
+ internal var lookaheadCache: LookaheadCache?
170
+ internal var lookaheadCachePrefetcher: LookaheadCachePrefetcher?
171
+
172
+ /// Live lookahead cache config — initialised from defaults on
173
+ /// first configure, mutated by `setLookaheadCache`.
174
+ /// Persists across destroy → configure so a consumer who set
175
+ /// custom values once doesn't lose them on a reconfigure cycle.
176
+ internal var lookaheadConfig: LookaheadCacheConfig = LookaheadCacheConfig(
177
+ enabled: true,
178
+ lookaheadCount: Double(LookaheadCachePrefetcher.DEFAULT_LOOKAHEAD_COUNT)
179
+ )
180
+
181
+ /// Cache-status callback registered via `onCacheStatusChange`.
182
+ /// Single-slot — the JS consumer owns the subscription via the
183
+ /// returned unsubscribe closure.
184
+ internal let cacheStatusListeners = ListenerRegistry<(CacheStatus) -> Void>()
185
+ internal let nowPlayingFormatListeners =
186
+ ListenerRegistry<(Variant_NullType_NowPlayingFormat?) -> Void>()
187
+
188
+ /// Cached snapshot of the active item's audio format. Returned
189
+ /// synchronously by `getNowPlayingFormat()`. `nil` between queue-end
190
+ /// (or pre-first-track) and the first successful resolve.
191
+ internal var lastEmittedFormat: NowPlayingFormat?
192
+
193
+ /// Identity key for `lastEmittedFormat`'s source AVPlayerItem. The
194
+ /// async resolver discards results whose source item is no longer
195
+ /// current and whose generation token has been superseded.
196
+ internal var lastEmittedFormatItemId: ObjectIdentifier?
197
+
198
+ /// Monotonic generation token bumped on every refresh request. Any
199
+ /// in-flight extractor Task whose captured token mismatches the
200
+ /// current value drops its result — guards against the cache-hit race
201
+ /// where `\.status == .readyToPlay` fires synchronously from the
202
+ /// status-observer bootstrap before `handleCurrentItemDidChange`
203
+ /// runs, producing two parallel resolves for the same item.
204
+ internal var nowPlayingFormatRequestGen: UInt64 = 0
205
+
206
+ /// True while a `reresolveNowPlayingFormatForBitrate` Task is mid-flight.
207
+ /// Access-log notifications fire multiple times per second during
208
+ /// streaming; without this gate every fire that lands while an extract
209
+ /// is still running would spawn another detached Task. Read+written on
210
+ /// the main actor only — set before the Task launches, cleared via
211
+ /// `defer` inside the MainActor.run completion (covers success, early-
212
+ /// return, and any future error path uniformly).
213
+ internal var bitrateReresolveInFlight: Bool = false
214
+
215
+ // MARK: - Events — callback storage + observer state
216
+ //
217
+ // Callbacks are stored on the main thread (registration funnels
218
+ // through `onMainSync`), read from the main thread (every observer
219
+ // body dispatches to main before reading), and invoked on the main
220
+ // thread. Nitro's callback plumbing handles cross-thread delivery
221
+ // into JS — our job is just to call them with the right values.
222
+ //
223
+ // State + track change flow out of KVO. Progress flows out of
224
+ // `addPeriodicTimeObserver` at 2 Hz (NOTES.md §5: "~1 Hz normal,
225
+ // faster when the consumer is actively scrubbing"). Error flows out
226
+ // of `AVPlayerItemFailedToPlayToEndTime`. Queue-end fires when
227
+ // AVQueuePlayer auto-advances past the last item (currentItem
228
+ // becomes nil while tracks is non-empty).
229
+ // Multi-listener registries — each `onXxx(callback)` registers an
230
+ // entry, returns a dispose closure that removes by id. Lets
231
+ // multiple consumers (Player UI, harness, test runner) subscribe
232
+ // simultaneously without clobbering each other.
233
+ private let stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Void>()
234
+ private let trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Void>()
235
+ private let progressListeners = ListenerRegistry<(PlayerProgress) -> Void>()
236
+ private let milestoneListeners = ListenerRegistry<(Double, Double) -> Void>()
237
+ /// Per-playthrough 25/50/75/90% milestone state. Main-thread-confined
238
+ /// (mutated only from the progress tick, `seekTo`, and the track-change
239
+ /// delegates — all on main), so no extra synchronisation is needed.
240
+ private var milestoneTracker = MilestoneTracker()
241
+ /// Throttles `onProgress` JS emission to the configured foreground /
242
+ /// background cadence. Main-thread-confined (mutated only from the progress
243
+ /// tick + cast push + the app-lifecycle notifications, all on main).
244
+ private var progressEmissionGate = ProgressEmissionGate()
245
+ /// NotificationCenter tokens for the `didEnterBackground` /
246
+ /// `willEnterForeground` observers that drive `progressEmissionGate`'s
247
+ /// background state. Registered per instance in `configure()` and removed in
248
+ /// `internalDestroy()` — NOT tied to the AVQueuePlayer event observers, whose
249
+ /// install/teardown runs on every engine swap.
250
+ private var appLifecycleObservers: [NSObjectProtocol] = []
251
+ private let errorListeners = ListenerRegistry<(PlaybackError) -> Void>()
252
+ private let queueEndListeners = ListenerRegistry<() -> Void>()
253
+ // SkipCapability struct — wraps (canSkipPrevious, canSkipNext).
254
+ // Struct rather than separate bool args lets future capability
255
+ // flags be added without a breaking change AND dodges a Nitrogen
256
+ // 0.35.4 (bool, bool) callback codegen bug.
257
+ private let skipCapabilityListeners = ListenerRegistry<(SkipCapability) -> Void>()
258
+
259
+ /// Last-emitted skip capability tuple. Native owns the truth; the
260
+ /// JS layer is a passive observer. Recomputed via
261
+ /// `recomputeCapabilities()` at every `tracks` / `currentTrackIndex`
262
+ /// / `repeatModeState` write site, with dedup —
263
+ /// the listener fires only when the tuple actually changes. Single
264
+ /// reference write means JS callers reading both fields via
265
+ /// `getSkipCapability()` see a consistent pair without a torn-read
266
+ /// risk; the legacy `getCanSkipNext` / `getCanSkipPrevious` getters
267
+ /// delegate to this same struct.
268
+ private var cachedSkipCapability: SkipCapability =
269
+ SkipCapability(canSkipPrevious: false, canSkipNext: false)
270
+
271
+ // Buffer state — discrete empty/buffering/stalled/full for the active track, derived
272
+ // from the item's native playback-buffer flags. Recomputed on the gapless
273
+ // item's buffer KVO (sub-tick latency) and on the progress tick (covers the
274
+ // crossfade engine + safety net); emitted only on an actual change.
275
+ private let bufferStateListeners = ListenerRegistry<(BufferState) -> Void>()
276
+ private var currentBufferState: BufferState = .empty
277
+ private var lastEmittedBufferState: BufferState?
278
+ private var bufferEmptyObserver: NSKeyValueObservation?
279
+ private var bufferKeepUpObserver: NSKeyValueObservation?
280
+ private var bufferFullObserver: NSKeyValueObservation?
281
+ /// Fully-buffered — the whole track has downloaded. Distinct from
282
+ /// `BufferState.full` ("keeps up"). Recomputed on the buffer-full KVO + the
283
+ /// progress tick + track change; emitted on change. Reset per track.
284
+ private let fullyBufferedListeners = ListenerRegistry<(Bool) -> Void>()
285
+ private var currentFullyBuffered = false
286
+ private var lastEmittedFullyBuffered: Bool?
287
+ /// True once the current track has actually reached a playing state — the
288
+ /// discriminator between an initial `buffering` load and a mid-playback
289
+ /// `stalled` rebuffer. Reset on track change / stop / teardown.
290
+ private var hasStartedPlaying = false
291
+ /// Lib-level playback intent (play() → true, pause()/stop() → false). A
292
+ /// not-full buffer only reads as `stalled` while playback is both started and
293
+ /// intended; a paused load reads as `buffering`.
294
+ private var wantsToPlay = false
295
+
296
+ /// Sleep-timer state machine (wall-clock countdown, 10s fade, 60s tail rule).
297
+ /// Player/timer-free — this class drives it from `sleepTimerTimer` and applies
298
+ /// the returned fade/pause decision to the engine.
299
+ private var sleepTimerCore = SleepTimerCore()
300
+ private let sleepTimerListeners = ListenerRegistry<(SleepTimerState) -> Void>()
301
+ /// Repeating 0.5s wall-clock timer that ticks `sleepTimerCore`. Runs whether
302
+ /// playback is playing or paused (a wall-clock deadline must keep counting
303
+ /// while paused, so this can't piggyback on the progress observer).
304
+ private var sleepTimerTimer: Timer?
305
+ /// True while a fade is in progress — the engine volume is being driven below
306
+ /// `volumeState`. Cleared when the fade restores full volume or the timer
307
+ /// tears down, so `volumeState` (and `getVolume`) stay truthful.
308
+ private var sleepTimerFading = false
309
+
310
+ /// Token from `AVPlayer.addPeriodicTimeObserver`, paired with the
311
+ /// specific `AVQueuePlayer` instance that issued it. `removeTime
312
+ /// Observer` has to be called on the issuing player — keeping the
313
+ /// reference here guards against a tear-down call arriving after
314
+ /// `self.player` has been replaced by a later `configure`.
315
+ private var progressTimeObserver: (player: AVQueuePlayer, token: Any)?
316
+
317
+ /// Engine-agnostic progress fallback — used when `self.player`
318
+ /// is nil (CrossfadeEngine active). Polls
319
+ /// `engine.currentPositionSeconds` at the same 0.5s cadence the
320
+ /// `AVPlayer.addPeriodicTimeObserver` would. Started on engine
321
+ /// swap to a non-AVQueuePlayer backend; stopped on swap back to
322
+ /// gapless or on destroy.
323
+ private var progressFallbackTimer: Timer?
324
+ /// Last position published by the CrossfadeEngine fallback-timer progress
325
+ /// path. Lets a paused tick emit once when the playhead moves (seek / stop)
326
+ /// without re-publishing identical tuples during a steady pause.
327
+ private var lastEmittedCrossfadePosition: TimeInterval = -1
328
+
329
+ /// KVO on `player.timeControlStatus`. Fires whenever AVPlayer
330
+ /// transitions between `.paused`, `.waitingToPlayAtSpecifiedRate`,
331
+ /// and `.playing` — translated into our PlayerState enum.
332
+ private var timeControlStatusObserver: NSKeyValueObservation?
333
+
334
+ /// Dedup key for `errorListeners` fires. Keyed on
335
+ /// `(queueItemId, standardized code)` so AVPlayer-internal item
336
+ /// rebuilds during retries do not bypass dedup. A DIFFERENT error
337
+ /// on the same item still surfaces because the code is part of the
338
+ /// key. Cleared on track change, setQueue, clearQueue, destroy,
339
+ /// and on retry-initiation so the retry-then-fail can fire a
340
+ /// fresh error.
341
+ private var lastErrorQueueItemId: String?
342
+ private var lastErrorCode: PlaybackErrorCode?
343
+
344
+ /// Auto-retry counter per queue position, keyed on `queueItemId`
345
+ /// (the lib-generated UUID, never a consumer field). Decremented on each
346
+ /// transient-error fire; when 0, the error surfaces to JS.
347
+ /// Re-populated on setQueue / addToQueueBody from
348
+ /// `effectiveAutoRetries()` (clamped 0..5; default 3). Reset to 1
349
+ /// (single shot) by `play()` if state is `.error` and the counter
350
+ /// is 0 — stuck-recovery on user-initiated re-tap.
351
+ private var retryAttemptsRemaining: [String: Int] = [:]
352
+
353
+ /// Last PlayerState emitted — used to suppress duplicate emissions.
354
+ /// KVO fires for any status transition, but our PlayerState enum
355
+ /// collapses several AVPlayer states so re-emission is possible.
356
+ private var lastReportedState: PlayerState = .none
357
+
358
+ /// End-of-queue latch. iOS has no native "ended" player state, so this is set
359
+ /// when the queue drains with repeat off (`detectQueueEnd` / `enginePlaybackEnded`)
360
+ /// and drives the terminal `.ended` in `PlayerStateDerivation` for BOTH
361
+ /// engines. Cleared when playback resumes (`emitState(.playing)`), a new track
362
+ /// loads (`dispatchTrackChange`), on seek, and on destroy. Main-thread-confined.
363
+ private var reachedQueueEnd = false
364
+
365
+ /// User-facing reason carrier for the `onTrackChange(_, _, reason)`
366
+ /// emission. When a skip method (or `setQueue` /
367
+ /// `applyNewCurrentIndex`) stashes a value here, the subsequent
368
+ /// `\.currentItem` KVO fire reads it and emits with the right
369
+ /// hint (`.userSkipNext`, `.userSkipPrevious`, `.userSkipToIndex`,
370
+ /// `.queueReplaced`, etc). Consumed on a non-nil transition; the
371
+ /// `isMutatingPlayerQueue + currentItem == nil` early-return in
372
+ /// `handleCurrentItemDidChange` skips the intermediate `nil` fire
373
+ /// inside a rebuild so the reason isn't stolen by the wrong fire.
374
+ /// Defaults to `.autoAdvance` when nil at consumption time.
375
+ ///
376
+ /// Reason-carrier only — the `matchTrackIndex` resync gate lives
377
+ /// on `didPlayToEndPending` so chain-advance bounces never re-run
378
+ /// the resync.
379
+ private var pendingTrackChangeReason: TrackChangeReason?
380
+
381
+ /// Counterpart to `pendingTrackChangeReason` for state transitions.
382
+ /// `play` / `pause` / `stop` / `seekTo` stash `.user` before calling
383
+ /// the native method; the `timeControlStatus` KVO picks it up to
384
+ /// attribute the resulting state change. Default is `.system` when
385
+ /// nothing has stashed a reason (covers buffering, auto-advance
386
+ /// end-of-queue; AudioSession-driven interruptions and route
387
+ /// changes carry their own reasons).
388
+ private var pendingStateChangeReason: StateChangeReason?
389
+
390
+ /// Bridges receiver-driven cast session state into the lib's
391
+ /// existing event surface. Lazily constructed and held for the
392
+ /// `TrackPlayer`'s lifetime; `start()` on configure, `stop()` on
393
+ /// destroy.
394
+ private lazy var castEventBridge = CastEventBridge(owner: self)
395
+
396
+ /// Rises whenever a mutation is actively rearranging the player's
397
+ /// internal queue (setQueue, addToQueue, removeFromQueue,
398
+ /// moveInQueue, clearQueue, applyNewCurrentIndex,
399
+ /// fullRebuildPlayerQueue). Every `removeAllItems` + `insert` pair
400
+ /// produces an intermediate `currentItem == nil` KVO fire — the
401
+ /// event dispatch path checks this flag and skips the intermediate
402
+ /// fire so `onQueueEnd` isn't falsely emitted mid-rebuild and
403
+ /// `pendingTrackChangeReason` isn't consumed by the wrong fire.
404
+ /// Nested (mutations calling each other) safe because each nest
405
+ /// uses `performingMutation` which restores the prior value on exit.
406
+ private var isMutatingPlayerQueue: Bool = false
407
+
408
+ // MARK: - Gapless — observer state
409
+ //
410
+ // AVQueuePlayer does inter-track transitions for free when three
411
+ // conditions hold: (1) `actionAtItemEnd == .advance` (AVQueuePlayer's
412
+ // default; set explicitly in configure for resilience), (2) the
413
+ // next AVPlayerItem is preloaded in player.items() before the
414
+ // current ends (AVQueueBuilder + our surgical inserts keep that
415
+ // invariant), and (3) `automaticallyWaitsToMinimizeStalling == false`
416
+ // so the player doesn't pause at the boundary waiting for minimum
417
+ // buffer on the new item.
418
+ //
419
+ // The flip-pattern: start with stalling=true so the *first* track
420
+ // buffers properly
421
+ // before playback begins; once the first item reaches readyToPlay,
422
+ // flip stalling=false so every subsequent inter-track transition
423
+ // is tight. We re-arm on setQueue/clear because a fresh queue's
424
+ // first track needs the same initial-buffer leniency.
425
+ private var currentItemObserver: NSKeyValueObservation?
426
+ private var currentItemStatusObserver: NSKeyValueObservation?
427
+ private var gaplessFlipArmed: Bool = true
428
+
429
+ /// Set to true by the `AVPlayerItemDidPlayToEndTime` notification
430
+ /// handler when an item finishes playback naturally. Consumed by
431
+ /// `handleCurrentItemDidChange` as the SOLE signal for "this is a
432
+ /// real auto-advance" — gates the `matchTrackIndex` resync so
433
+ /// AVQueuePlayer chain-advance through unbuffered items (which
434
+ /// does NOT produce a DidPlayToEndTime notification) cannot
435
+ /// overwrite the user's authoritative `currentTrackIndex`.
436
+ private var didPlayToEndPending: Bool = false
437
+
438
+ /// Last `queueItemId` for which `trackChangeListeners` were fired.
439
+ /// Compared against the current item's `queueItemId` in
440
+ /// `handleCurrentItemDidChange` to dedup redundant emissions
441
+ /// across the chain-advance bounce + observer-reinstall classes.
442
+ /// Reset to nil on `destroy` (the next configure starts fresh).
443
+ ///
444
+ /// Why queueItemId not currentTrackIndex: setQueue replaces the
445
+ /// queue but may keep `currentTrackIndex == 0`; comparing index
446
+ /// alone would suppress the legitimate trackChange for the new
447
+ /// queue's first item. queueItemId differs across queue
448
+ /// replacements (UUID-per-item-at-construction), so the gate
449
+ /// fires emissions correctly for setQueue + skip + auto-advance
450
+ /// while still suppressing redundant fires.
451
+ private var lastEmittedQueueItemId: String? = nil
452
+
453
+ /// Cached classification of the active track's playback source.
454
+ /// Recomputed in `dispatchTrackChange` whenever the active track
455
+ /// changes (queue replace, skip, auto-advance, shuffle). Read by
456
+ /// `getCurrentTrackSource()` for sync JS access.
457
+ private var currentTrackSource: TrackSource? = nil
458
+
459
+ // Records mach-time at item-ended so the next auto-advance dispatch
460
+ // can compute a silence-gap delta, surfaced as `onTrackChange`'s
461
+ // `transitionGapMs`. Always-on (one UInt64 store on a low-frequency
462
+ // event). Verbose `[RNQP-GAPLESS]` diagnostic NSLogs against these
463
+ // timestamps are gated separately under `#if DEBUG`.
464
+ private var gaplessGapPrevEndedAtNs: UInt64 = 0
465
+
466
+ /// Latest `TrackItem` emitted by `dispatchTrackChange`. Snapshot
467
+ /// readable by SiriKit intent handlers that need a "current track"
468
+ /// seed without routing through JS. Updated on every natural track
469
+ /// change; nil when no queue is loaded. Read via
470
+ /// `TrackPlayer.cachedCurrentTrack`.
471
+ static var cachedCurrentTrack: TrackItem? = nil
472
+ // Captured at the top of `handleCurrentItemDidChange` so the
473
+ // `transitionGapMs` measurement reflects pure iOS scheduling latency
474
+ // between `AVPlayerItemDidPlayToEndTime` and the `\.currentItem` KVO fire,
475
+ // not the synchronous lib-side work `dispatchTrackChange` does
476
+ // before reaching the emit site (matchTrackIndex iteration,
477
+ // trackChangeListeners fan-out, refreshNowPlayingFormatForActiveItem,
478
+ // nowPlayingInfo.refreshAll, artworkResolver.resolve prelude). That
479
+ // intra-method work is sometimes 10s of ms on a cold first
480
+ // transition; measuring through it would conflate "scheduler
481
+ // latency" with "lib processing time" — the test contract is the
482
+ // former.
483
+ private var gaplessGapMeasuredAtNs: UInt64 = 0
484
+ #if DEBUG
485
+ // Debug-only timestamps backing the verbose `[RNQP-GAPLESS]` NSLog
486
+ // emits. Compiled out entirely in Release builds.
487
+ private var gaplessLogLastEndedAtNs: UInt64 = 0
488
+ private var gaplessLogLastChangedAtNs: UInt64 = 0
489
+ #endif
490
+
491
+ // MARK: - Lifecycle
492
+
493
+ func configure(config: PlayerConfig) throws -> Promise<Void> {
494
+ return Promise<Void>.async {
495
+ await self.onMain {
496
+ // Universal auto-destroy contract: every `configure()` call
497
+ // tears down any prior engine, audio session, observers, and
498
+ // pending pipeline state before reinitialising. On the first
499
+ // configure of a session this is a no-op (the gate inside
500
+ // `internalDestroy` short-circuits when `self.player == nil`).
501
+ // On subsequent calls it is a real teardown. Effect: configure
502
+ // is always a clean-slate operation; consumers don't need to
503
+ // remember to call `destroy()` before re-configuring.
504
+ // Capture the prior eviction policy before overwriting config — a change
505
+ // clears the rebuilt cache below so it starts fresh under the new policy
506
+ // (infrequent; a cache's eviction ordering is construction-fixed).
507
+ let previousEvictionPolicy = self.config.lookaheadCacheEvictionPolicy ?? .lru
508
+ self.internalDestroy()
509
+ self.config = config
510
+ let evictionPolicyChanged =
511
+ previousEvictionPolicy != (config.lookaheadCacheEvictionPolicy ?? .lru)
512
+ // Seed the progress-emission throttle from config and start observing
513
+ // app background/foreground. `internalDestroy()` above removed any
514
+ // prior observers, so this re-registers cleanly on every configure.
515
+ self.applyProgressEmissionIntervals()
516
+ self.installAppLifecycleObservers()
517
+ // Pin-at-configure visualization-disabled flag. Mirrored to
518
+ // the AudioTapProvider singleton so `Visualizer.subscribe`
519
+ // and the Visualizer Consumer's `wantsTap` predicate can
520
+ // read it live. Default `true` preserves existing behaviour.
521
+ AudioTapProvider.shared.setVisualizationEnabled(
522
+ config.visualizationEnabled ?? true
523
+ )
524
+ let engine = GaplessEngine()
525
+ self.engine = engine
526
+ // configure() always rebuilds the default GaplessEngine, so the mode
527
+ // state must reset to match. Otherwise getPlaybackMode() keeps
528
+ // reporting a stale crossfade selection after a clean-slate
529
+ // reconfigure while the engine is actually gapless — and a subsequent
530
+ // setPlaybackMode(.crossfade) would no-op against that stale state and
531
+ // never rebuild the CrossfadeEngine.
532
+ self.playbackModeState = PlaybackModeStateMachine.initial
533
+ let player = engine.player
534
+ self.player = player
535
+ self.gaplessFlipArmed = true
536
+ self.installGaplessObservers(on: player)
537
+ self.installEventObservers(on: player)
538
+ // Re-apply persisted config to the fresh engine. These survive a
539
+ // destroy→configure cycle and may be set before the first
540
+ // configure, so the first engine must pick them up — matching the
541
+ // engine-swap path. Speed is seeded (not set) so it doesn't start
542
+ // the paused player via AVPlayer.rate; play() applies it on the
543
+ // first resume.
544
+ engine.setRepeatMode(self.repeatModeState)
545
+ engine.setVolume(self.volumeState)
546
+ engine.seedPlaybackSpeed(self.playbackSpeedState)
547
+ engine.setPitchCorrectionMode(self.pitchCorrectionModeState)
548
+ self.wireAudioSession()
549
+ // Start observing receiver-driven cast session state; idempotent
550
+ // on re-configure (no-op if already started).
551
+ self.castEventBridge.start()
552
+ // Activate the lockscreen mirror for cast sessions. Subscribes
553
+ // through `PlaybackStateRouter.shared` — fires on every
554
+ // session-active transition.
555
+ CastNowPlayingController.shared.start()
556
+ // Wire the shared audio-tap provider onto the engine. The
557
+ // provider attaches an MTAudioProcessingTap-backed
558
+ // AVAudioMix to each queued AVPlayerItem when any
559
+ // registered consumer (EQ, visualizer) wants the tap.
560
+ AudioTapProvider.shared.wireToPlaybackEngine(engine)
561
+ // Notify subscribers when a tap-side RG extract lands so any
562
+ // `onNowPlayingFormatChange` listener picks up the new RG
563
+ // values immediately. `getNowPlayingFormat()` reads RG live
564
+ // from `AudioTapProvider`, so the synchronous read path
565
+ // doesn't depend on this callback — it's purely for change-
566
+ // notification subscribers.
567
+ AudioTapProvider.shared.onItemReplayGainDataPopulated = { [weak self] item in
568
+ guard let self else { return }
569
+ self.emitCurrentNowPlayingFormat(for: item)
570
+ }
571
+ self.audioSession.activate(
572
+ mode: AudioCategoryMapping.mode(for: config.audioContentType),
573
+ options: AudioCategoryMapping.options(
574
+ for: config.audioCategoryOptions,
575
+ contentType: config.audioContentType
576
+ )
577
+ )
578
+ self.audioSession.installObservers()
579
+ self.wireNowPlayingInfo()
580
+ self.nowPlayingInfo.startPeriodicTimer()
581
+ self.wireRemoteCommands()
582
+ self.remoteCommands.install()
583
+ self.remoteCommands.setSkipCapability(
584
+ canSkipNext: self.cachedSkipCapability.canSkipNext,
585
+ canSkipPrevious: self.cachedSkipCapability.canSkipPrevious
586
+ )
587
+ // Refresh on every configure() so a header / UA update lands
588
+ // on the next artwork resolve. Live config — read fresh on
589
+ // each track-change resolve.
590
+ self.artworkResolver.configHeaders = config.httpHeaders
591
+ self.artworkResolver.configUserAgent = config.userAgent
592
+ // Same live config for the cast lockscreen's own artwork
593
+ // resolver so authenticated covers resolve during cast.
594
+ CastNowPlayingController.shared.configureArtwork(
595
+ headers: config.httpHeaders, userAgent: config.userAgent
596
+ )
597
+ // Build the lookahead cache + prefetcher honouring the live
598
+ // `lookaheadConfig`. When disabled, neither is built.
599
+ // `internalDestroy` above already nilled out any prior cache
600
+ // + prefetcher, so this is always a fresh construction.
601
+ if self.lookaheadConfig.enabled {
602
+ let cache = LookaheadCache(
603
+ maxSizeBytes: LookaheadCache.maxBytes(
604
+ forMb: self.configuredCacheMaxSizeMb()
605
+ ),
606
+ evictionPolicy: self.configuredEvictionPolicy()
607
+ )
608
+ // A policy change wipes the retained files so the cache refills
609
+ // under the new eviction order.
610
+ if evictionPolicyChanged {
611
+ cache.clear()
612
+ }
613
+ self.lookaheadCache = cache
614
+ let prefetcher = LookaheadCachePrefetcher(
615
+ cache: cache,
616
+ configHeaders: config.httpHeaders,
617
+ configUserAgent: config.userAgent
618
+ )
619
+ prefetcher.defaultLookaheadCount =
620
+ max(0, Int(self.lookaheadConfig.lookaheadCount))
621
+ prefetcher.onTrackProcessed = { [weak self] in self?.emitCacheStatus() }
622
+ self.lookaheadCachePrefetcher = prefetcher
623
+ } else if evictionPolicyChanged {
624
+ // Cache disabled at configure — no instance to clear(); purge the
625
+ // on-disk files directly so a policy change still starts fresh
626
+ // (parity with Android's unconditional clear).
627
+ LookaheadCache.purgeDefaultDirectory()
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ /// Install AudioSession interruption + route-change callbacks on
634
+ /// top of the transport. Centralised here so both callback types
635
+ /// share the same emit-state-then-call-native-method pattern.
636
+ private func wireAudioSession() {
637
+ self.audioSession.onInterruption = { [weak self] kind in
638
+ guard let self else { return }
639
+ let player = self.player
640
+ switch kind {
641
+ case .began:
642
+ // System wants us paused (phone call, Siri, alarm). Pause
643
+ // + stash `.interruption` for the resulting `timeControl
644
+ // Status` KVO to pick up on the terminal `.paused` emit.
645
+ self.pendingStateChangeReason = .interruption
646
+ player?.pause()
647
+ case .endedShouldResume:
648
+ // System says it's safe to resume. Resume through the engine so
649
+ // the user's playback speed is restored (a raw AVPlayer.play()
650
+ // resets rate to 1.0) and crossfade — whose legs the OS paused on
651
+ // `.began` but which has no `self.player` to act on — resumes too.
652
+ // play() transitions via `.buffering` → `.playing`; buffering
653
+ // passes through as `.system` so the reason sticks until
654
+ // `.playing` lands.
655
+ self.pendingStateChangeReason = .interruption
656
+ self.engine?.play()
657
+ case .endedShouldNotResume:
658
+ // Interruption ended but user resolved it in a way that
659
+ // shouldn't restart music. The player is already paused
660
+ // (OS pre-paused on `.began`, we stayed paused), so no
661
+ // transport call and no state transition to report.
662
+ // Apple's docs confirm clients should not resume — no
663
+ // client action needed; a dedicated `onInterruptionEnd`
664
+ // event could land later if consumers need to clear UI
665
+ // annotations.
666
+ break
667
+ }
668
+
669
+ // Surface the interruption kind as a typed non-fatal
670
+ // `PlaybackError` so JS consumers can branch on the resume
671
+ // hint without inspecting a boolean. Wire literals + messages
672
+ // come from `InterruptionEventMapping` (single source of
673
+ // truth for the three nativeDomain strings).
674
+ let domain = InterruptionEventMapping.nativeDomain(for: kind)
675
+ let message = InterruptionEventMapping.message(for: kind)
676
+ let err = PlaybackError(
677
+ code: .unknown,
678
+ message: message,
679
+ fatal: false,
680
+ nativeCode: 0,
681
+ nativeDomain: domain,
682
+ nativeMessage: message,
683
+ queueItemId: "",
684
+ url: ""
685
+ )
686
+ self.errorListeners.forEach { $0(err) }
687
+ }
688
+
689
+ self.audioSession.onRouteChange = { [weak self] kind in
690
+ guard let self else { return }
691
+ switch kind {
692
+ case .oldDeviceUnavailable:
693
+ // Headphones unplugged, BT disconnected — never blast
694
+ // music over the speaker per Apple HIG.
695
+ guard let player = self.player else { return }
696
+ self.pendingStateChangeReason = .routeChange
697
+ player.pause()
698
+ case .newDeviceAvailable:
699
+ // New route arrived (BT connected, headphones plugged in).
700
+ // No automatic transport action; state is unchanged, so
701
+ // no emission either. Consumers wanting to observe new
702
+ // devices can subscribe to AVAudioSession notifications
703
+ // directly if needed.
704
+ break
705
+ }
706
+ }
707
+
708
+ // Audio-session lifecycle failures (`setCategory` /
709
+ // `setActive(true)` / `setActive(false)`) surface as non-fatal
710
+ // PlaybackErrors with stable codes (see `AudioSession.onError`
711
+ // doc). Deliberately do NOT call `emitState(.error, ...)` —
712
+ // these are session-level failures, not player-level; the
713
+ // player itself isn't in an error state and may still recover
714
+ // (e.g. another app released the session).
715
+ self.audioSession.onError = { [weak self] sessionError in
716
+ guard let self else { return }
717
+ // Audio-session errors don't have a meaningful AVPlayer-level
718
+ // standardized code (they're session-scope, not playback-
719
+ // scope). Surface as `.unknown` with the SCREAMING_SNAKE_CASE
720
+ // session code in `nativeDomain` so consumers can branch on
721
+ // `nativeDomain.startsWith("AUDIO_SESSION_")` if they need
722
+ // session-specific UX.
723
+ // Apply the same `stripQuery` PII safeguard as the rest of
724
+ // the error fire sites — audio-session messages today don't
725
+ // carry URLs, but defense in depth.
726
+ let safeMessage = PlaybackErrorMapping.stripQuery(sessionError.message)
727
+ // Audio-session errors are session-scope; no specific queue
728
+ // item to correlate, so queueItemId + url are empty.
729
+ let err = PlaybackError(
730
+ code: .unknown,
731
+ message: safeMessage,
732
+ fatal: false,
733
+ nativeCode: Double(sessionError.nativeCode),
734
+ nativeDomain: sessionError.nativeDomain,
735
+ nativeMessage: safeMessage,
736
+ queueItemId: "",
737
+ url: ""
738
+ )
739
+ self.errorListeners.forEach { $0(err) }
740
+ }
741
+ }
742
+
743
+ /// Map `MPRemoteCommandCenter` events to the public transport
744
+ /// methods. The closure stamps `pendingStateChangeReason = .system`
745
+ /// before invoking the transport call so the conditional
746
+ /// `pendingStateChangeReason ?? .user` write inside each transport
747
+ /// method short-circuits and the resulting state-change emit
748
+ /// carries `.system`.
749
+ ///
750
+ /// Race window: between the synchronous `.system` stamp and the
751
+ /// transport's Task-bodied `await self.onMain { ... }` hop, an
752
+ /// unrelated main-queue block (e.g. an `.interruption` stamp from
753
+ /// `wireAudioSession`) can overwrite the slot. The practical impact
754
+ /// is bounded — the default reason in `emitStateChangeIfChanged` is
755
+ /// `.system`, so a stolen `.system` re-falls-through to `.system` on
756
+ /// the next consumer. An `.interruption` interleave tags the
757
+ /// state-change as `.interruption` (semantically correct — the
758
+ /// interruption IS why the state changed); the subsequent play()
759
+ /// emit defaults to `.user`. Acceptable degradation.
760
+ ///
761
+ /// Togglers: `.togglePlayPause` flips off ANY actively-progressing
762
+ /// state, not just `.playing` — `.buffering` (waiting-to-play)
763
+ /// counts as "user is trying to play" and should pause on tap.
764
+ ///
765
+ /// `[weak self]` prevents the cycle TrackPlayer → remoteCommands →
766
+ /// onCommand → TrackPlayer (TrackPlayer owns RemoteCommands as a
767
+ /// `let` property).
768
+ private func wireRemoteCommands() {
769
+ self.remoteCommands.onCommand = { [weak self] command in
770
+ guard let self = self else { return }
771
+ self.pendingStateChangeReason = .system
772
+ switch command {
773
+ case .play:
774
+ _ = try? self.play()
775
+ case .pause:
776
+ _ = try? self.pause()
777
+ case .togglePlayPause:
778
+ let active =
779
+ self.lastReportedState == .playing ||
780
+ self.lastReportedState == .buffering
781
+ if active {
782
+ _ = try? self.pause()
783
+ } else {
784
+ _ = try? self.play()
785
+ }
786
+ case .stop:
787
+ _ = try? self.stop()
788
+ case .nextTrack:
789
+ _ = try? self.skipToNext()
790
+ case .previousTrack:
791
+ _ = try? self.skipToPrevious()
792
+ case .skipForward(let seconds):
793
+ // Chromecast: relative-seek the receiver from ITS position. AirPlay /
794
+ // local: `routeSeekBy` returns false (no GCK session), so seek the
795
+ // local engine — whose position is accurate (AVPlayer plays to the
796
+ // AirPlay route). `seekTo` clamps to [0, duration].
797
+ if !CastTransportRouter.routeSeekBy(deltaMs: Int64(seconds * 1000)) {
798
+ let base = self.engine?.currentPositionSeconds ?? 0
799
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) + seconds)
800
+ }
801
+ case .skipBackward(let seconds):
802
+ if !CastTransportRouter.routeSeekBy(deltaMs: -Int64(seconds * 1000)) {
803
+ let base = self.engine?.currentPositionSeconds ?? 0
804
+ _ = try? self.seekTo(position: (base.isFinite ? base : 0) - seconds)
805
+ }
806
+ case .changePlaybackPosition(let seconds):
807
+ _ = try? self.seekTo(position: seconds)
808
+ }
809
+ }
810
+ }
811
+
812
+ /// Set the `NowPlayingInfo` snapshot provider. Reads `currentTrackIndex`,
813
+ /// the AVPlayer's current item position + duration, and the player rate
814
+ /// in the same main-thread tick — all four values come from the same
815
+ /// observable state so the lock-screen scrubber doesn't see torn reads.
816
+ /// Returning `nil` track + zero values when the player is gone is
817
+ /// observed as "queue empty" by `NowPlayingInfo.refreshAll`, which then
818
+ /// clears the dictionary.
819
+ private func wireNowPlayingInfo() {
820
+ self.nowPlayingInfo.snapshotProvider = { [weak self] in
821
+ guard let self else {
822
+ return NowPlayingInfo.Snapshot(
823
+ track: nil, elapsedSeconds: 0, durationSeconds: 0, rate: 0)
824
+ }
825
+ let idx = self.currentTrackIndex
826
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
827
+ ? self.tracks[idx] : nil
828
+ let item = self.player?.currentItem
829
+ let elapsed = item.map { CMTimeGetSeconds($0.currentTime()) } ?? 0
830
+ let duration = item.map { CMTimeGetSeconds($0.duration) } ?? 0
831
+ let rate = self.player?.rate ?? 0
832
+ return NowPlayingInfo.Snapshot(
833
+ track: track,
834
+ elapsedSeconds: elapsed,
835
+ durationSeconds: duration,
836
+ rate: rate
837
+ )
838
+ }
839
+ }
840
+
841
+ func destroy() throws -> Promise<Void> {
842
+ return Promise<Void>.async {
843
+ await self.onMain {
844
+ self.internalDestroy()
845
+ }
846
+ }
847
+ }
848
+
849
+ /// Synchronous teardown. Idempotent — no-op when nothing is
850
+ /// configured. Called from public `destroy()` and from `configure()`
851
+ /// (which auto-destroys before reinitialising; see `configure(_:)`
852
+ /// for the universal contract).
853
+ ///
854
+ /// Persists `config` / `repeatModeState` / `lookaheadConfig` across
855
+ /// the teardown so a consumer who called `setRepeatMode()` /
856
+ /// `setLookaheadCache()` doesn't lose those settings on a
857
+ /// reconfigure cycle. Matches Android's symmetric behaviour.
858
+ private func internalDestroy() {
859
+ // Idempotent gate: when nothing has been configured, every line
860
+ // below would either no-op (nil-coalesced calls) or perform
861
+ // unnecessary work against fresh state. The `player == nil`
862
+ // sentinel is the canonical "is configured?" check.
863
+ //
864
+ // Note on partial-init safety: `self.player = engine.player` is
865
+ // assigned in `configure(_:)` BEFORE `audioSession.activate`,
866
+ // `installObservers`, `wireNowPlayingInfo`, `remoteCommands.install`.
867
+ // If any of those subsequent steps ever fails (none can today —
868
+ // they're synchronous and non-throwing), `self.player` is already
869
+ // set, so this gate admits cleanup of every partially-installed
870
+ // resource. The audioSession + observers tear-down lines below
871
+ // are individually idempotent.
872
+ guard self.player != nil else { return }
873
+ // Tear the prefetcher down BEFORE the cache. Cancelling the
874
+ // prefetcher's Task chain arms cooperative cancellation so no
875
+ // new download dispatches against the about-to-die URLSession;
876
+ // the cache's `tearDown` then invalidates the session, which
877
+ // is what actually kills any already-suspended
878
+ // URLSessionDownloadTask mid-flight.
879
+ self.lookaheadCachePrefetcher?.tearDown()
880
+ self.lookaheadCachePrefetcher = nil
881
+ self.lookaheadCache?.tearDown()
882
+ self.lookaheadCache = nil
883
+
884
+ self.tearDownGaplessObservers()
885
+ self.tearDownEventObservers()
886
+ self.removeAppLifecycleObservers()
887
+ self.audioSession.tearDownObservers()
888
+ self.audioSession.onInterruption = nil
889
+ self.audioSession.onRouteChange = nil
890
+ self.audioSession.onError = nil
891
+ self.nowPlayingInfo.clear()
892
+ self.nowPlayingInfo.snapshotProvider = nil
893
+ self.remoteCommands.uninstall()
894
+ self.remoteCommands.onCommand = nil
895
+ self.artworkResolver.reset()
896
+ // CarPlay coordinator state lives for process lifetime. The
897
+ // bridge slots survive destroy — the subscription is owned by
898
+ // the JS consumer (re-registering replaces the slot), matching
899
+ // the documented Android behaviour. Browse data, in-flight
900
+ // resolver promises, and the artwork cache reset with the
901
+ // configure cycle.
902
+ let coordinator = CarPlayCoordinator.shared
903
+ coordinator.dataProvider.setSnapshot(nil)
904
+ coordinator.pending.clear()
905
+ coordinator.loader.clearCache()
906
+ // engine.release() drains the underlying AVQueuePlayer's items +
907
+ // nils its mix provider.
908
+ self.engine?.release()
909
+ self.engine = nil
910
+ self.player = nil
911
+ self.stopProgressFallbackTimer()
912
+ self.stopSleepTimerTick()
913
+ self.sleepTimerCore.clear()
914
+ self.sleepTimerFading = false
915
+ self.queueState.clear()
916
+ self.currentTrackIndex = -1
917
+ self.currentTrackSource = nil
918
+ self.currentBufferState = .empty
919
+ self.lastEmittedBufferState = nil
920
+ self.currentFullyBuffered = false
921
+ self.lastEmittedFullyBuffered = nil
922
+ self.hasStartedPlaying = false
923
+ self.wantsToPlay = false
924
+ self.lastEmittedQueueItemId = nil
925
+ self.didPlayToEndPending = false
926
+ // `player == nil` now; refresh emits null + cancels in-flight
927
+ // extractor Tasks via the generation token.
928
+ self.refreshNowPlayingFormatForActiveItem()
929
+ // `lastErrorQueueItemId` / `lastErrorCode` reset alongside the
930
+ // player so a subsequent error on a different item still fires
931
+ // (the dedup is per-item).
932
+ self.lastErrorQueueItemId = nil
933
+ self.lastErrorCode = nil
934
+ self.retryAttemptsRemaining.removeAll()
935
+ self.lastReportedState = .none
936
+ self.reachedQueueEnd = false
937
+ self.pendingTrackChangeReason = nil
938
+ self.pendingStateChangeReason = nil
939
+ self.audioSession.deactivate()
940
+ // Tear down the cast event bridge — disposers unsubscribe from
941
+ // both the global router and any active session's state listener.
942
+ self.castEventBridge.stop()
943
+ // Tear down the cast lockscreen mirror.
944
+ CastNowPlayingController.shared.stop()
945
+ // Final (false, false) emit so any still-registered subscriber
946
+ // sees the player is gone. JS hooks dispose their listener on
947
+ // unmount; this covers stragglers + native-side subscribers.
948
+ self.recomputeCapabilities()
949
+ }
950
+
951
+ /// Attach the KVO chain that powers the stalling-flip gapless
952
+ /// pattern: observe `player.currentItem` for item transitions,
953
+ /// and for each current item observe `status` so we can flip
954
+ /// `automaticallyWaitsToMinimizeStalling` to false the moment
955
+ /// the current item reaches `.readyToPlay` while
956
+ /// `gaplessFlipArmed == true`. Safe to call repeatedly — previous
957
+ /// observers are invalidated first.
958
+ ///
959
+ /// `.new`-only KVO: `.initial` is deliberately NOT used. `.initial`
960
+ /// fires synchronously during observer registration on whatever
961
+ /// thread the install runs from, which can land mid-mutation and
962
+ /// skip the lib's debounce step (`pendingStateChangeReason`
963
+ /// consumption, `lastEmittedQueueItemId` dedup). The explicit
964
+ /// async-to-main bootstrap below replicates `.initial` semantics
965
+ /// by funnelling through the same handler the `.new` callback
966
+ /// uses, but on a stable post-registration state. See NOTES.md §18.
967
+ private func installGaplessObservers(on player: AVQueuePlayer) {
968
+ tearDownGaplessObservers()
969
+
970
+ currentItemObserver = player.observe(
971
+ \.currentItem, options: [.new]
972
+ ) { [weak self] _, _ in
973
+ // KVO fires on AVFoundation's internal queue; funnel back to
974
+ // main to stay consistent with every other player mutation.
975
+ DispatchQueue.main.async {
976
+ guard let self else { return }
977
+ self.attachStatusObserverIfArmed()
978
+ self.handleCurrentItemDidChange()
979
+ }
980
+ }
981
+ // Bootstrap to replicate what `.initial` would have delivered.
982
+ // Async-to-main matches the `.new` callback body's dispatch
983
+ // shape so a fresh-install cur-item is observed identically
984
+ // to a subsequent transition.
985
+ DispatchQueue.main.async { [weak self] in
986
+ guard let self else { return }
987
+ self.attachStatusObserverIfArmed()
988
+ self.handleCurrentItemDidChange()
989
+ }
990
+ }
991
+
992
+ private func attachStatusObserverIfArmed() {
993
+ currentItemStatusObserver?.invalidate()
994
+ currentItemStatusObserver = nil
995
+ tearDownBufferObservers()
996
+ guard let item = self.player?.currentItem else {
997
+ // No current item (empty queue / torn down) — settle to empty.
998
+ recomputeBufferState()
999
+ return
1000
+ }
1001
+
1002
+ // Two distinct concerns share this KVO:
1003
+ // 1. `.readyToPlay` flips `automaticallyWaitsToMinimizeStalling`
1004
+ // back to false (initial-buffer leniency one-shot).
1005
+ // 2. `.failed` surfaces an `onError` event — the only hook that
1006
+ // catches initial-load failures (closed port, 4xx/5xx HTTP,
1007
+ // malformed asset). Mid-playback failures arrive separately
1008
+ // via `AVPlayerItemFailedToPlayToEndTime`.
1009
+ //
1010
+ // `.new`-only KVO + synchronous bootstrap. The bootstrap is sync
1011
+ // (not main-async like the sibling `\.currentItem` /
1012
+ // `\.timeControlStatus` bootstraps) because this method is itself
1013
+ // called from main (from `\.currentItem` KVO body's main-async
1014
+ // block) — running the bootstrap inline catches a cache-hit path
1015
+ // where the asset resolves to `.readyToPlay` before observer
1016
+ // install would otherwise leave `gaplessFlipArmed` set forever.
1017
+ //
1018
+ // Re-entrancy: a `.failed` immediate resolution dispatches through
1019
+ // `handleCurrentItemFailedToLoad` → `dispatchErrorOrRetry`, which
1020
+ // may schedule a retry that calls `fullRebuildPlayerQueue`. The
1021
+ // rebuild's `tearDownGaplessObservers` invalidates the just-
1022
+ // installed observer reference; the synchronous bootstrap's local
1023
+ // `let observer` reference still holds but its observation is
1024
+ // disconnected. Safe — the inflight bootstrap path completes,
1025
+ // returns to `attachStatusObserverIfArmed`, returns to the parent
1026
+ // KVO body, and the rebuild's freshly-installed observer takes
1027
+ // over.
1028
+ currentItemStatusObserver = item.observe(
1029
+ \.status, options: [.new]
1030
+ ) { [weak self] item, _ in
1031
+ guard let self else { return }
1032
+ DispatchQueue.main.async {
1033
+ self.dispatchItemStatus(item)
1034
+ }
1035
+ }
1036
+ self.dispatchItemStatus(item)
1037
+ installBufferObservers(on: item)
1038
+ }
1039
+
1040
+ /// KVO on the gapless current item's three native playback-buffer flags.
1041
+ /// Any change recomputes + emits the discrete `BufferState` on the next main
1042
+ /// tick — sub-progress-tick latency for a stall / loading indicator.
1043
+ private func installBufferObservers(on item: AVPlayerItem) {
1044
+ let onChange: (AVPlayerItem, Any) -> Void = { [weak self] _, _ in
1045
+ DispatchQueue.main.async {
1046
+ self?.recomputeBufferState()
1047
+ self?.recomputeFullyBuffered()
1048
+ }
1049
+ }
1050
+ bufferEmptyObserver = item.observe(\.isPlaybackBufferEmpty, options: [.new], changeHandler: onChange)
1051
+ bufferKeepUpObserver = item.observe(\.isPlaybackLikelyToKeepUp, options: [.new], changeHandler: onChange)
1052
+ bufferFullObserver = item.observe(\.isPlaybackBufferFull, options: [.new], changeHandler: onChange)
1053
+ recomputeBufferState()
1054
+ recomputeFullyBuffered()
1055
+ }
1056
+
1057
+ private func tearDownBufferObservers() {
1058
+ bufferEmptyObserver?.invalidate(); bufferEmptyObserver = nil
1059
+ bufferKeepUpObserver?.invalidate(); bufferKeepUpObserver = nil
1060
+ bufferFullObserver?.invalidate(); bufferFullObserver = nil
1061
+ }
1062
+
1063
+ /// Single source of truth for the per-item `\.status` KVO body.
1064
+ /// Called both from the observer callback and from the synchronous
1065
+ /// bootstrap in `attachStatusObserverIfArmed`.
1066
+ private func dispatchItemStatus(_ item: AVPlayerItem) {
1067
+ switch item.status {
1068
+ case .readyToPlay:
1069
+ if self.gaplessFlipArmed, let player = self.player {
1070
+ player.automaticallyWaitsToMinimizeStalling = false
1071
+ self.gaplessFlipArmed = false
1072
+ }
1073
+ // The asset's audio track + format descriptions are reliably
1074
+ // loadable by the time we hit `.readyToPlay`. The post-track-
1075
+ // change null-emit may race ahead of asset metadata load on
1076
+ // first hit, so we re-resolve here to land the real format.
1077
+ self.refreshNowPlayingFormatForActiveItem()
1078
+ // Streaming-item audioMix catch-up for the gapless engine.
1079
+ // engine.setItems / insertItems install the mix at insert
1080
+ // time only for items whose `tracks` key is already loaded;
1081
+ // streaming items insert with `audioMix == nil` and pick up
1082
+ // their mix here once `.readyToPlay` confirms tracks have
1083
+ // loaded. The crossfade engine has a parallel catch-up path
1084
+ // through its `engineActiveItemFormatChanged` delegate hook —
1085
+ // both call into `refreshActiveItemMixes`, which is
1086
+ // idempotent on items that already carry a tap, so a double-
1087
+ // fire is harmless.
1088
+ AudioTapProvider.shared.refreshActiveItemMixes()
1089
+ case .failed:
1090
+ // Pause synchronously BEFORE surfacing the typed error. Without
1091
+ // this, AVQueuePlayer treats `.failed` as end-of-item and chain-
1092
+ // advances through subsequent items in the queue, walking
1093
+ // `currentTrackIndex` past the failure point while the JS error
1094
+ // event is still in flight. Pausing halts that walk so the
1095
+ // consumer sees the error against the failing track and can
1096
+ // decide whether to skip / retry / surface UI.
1097
+ self.player?.pause()
1098
+ self.handleCurrentItemFailedToLoad(item)
1099
+ default:
1100
+ break
1101
+ }
1102
+ }
1103
+
1104
+ /// Surface a `.failed` AVPlayerItem as an `onError` + state ERROR.
1105
+ /// Idempotent per (queueItemId, standardized-code) so AVPlayer's
1106
+ /// internal item-rebuild during retries doesn't bypass dedup. A
1107
+ /// retry that flips the same item to a DIFFERENT error code still
1108
+ /// surfaces because the code is part of the dedup key.
1109
+ ///
1110
+ /// `nativeDomain` on the emitted error is stamped to
1111
+ /// `PLAYER_ITEM_LOAD_FAILED` so JS consumers can distinguish item-
1112
+ /// load failures (the raw asset never reached `.readyToPlay`) from
1113
+ /// mid-playback failures (which arrive via
1114
+ /// `AVPlayerItemFailedToPlayToEndTime` and keep the underlying
1115
+ /// `NSError.domain`). The underlying NSError code + message remain
1116
+ /// available via `nativeCode` + `nativeMessage`.
1117
+ private func handleCurrentItemFailedToLoad(_ item: AVPlayerItem) {
1118
+ guard item.status == .failed else { return }
1119
+ let underlying = item.error as NSError?
1120
+ dispatchErrorOrRetry(
1121
+ item: item,
1122
+ underlying: underlying,
1123
+ nativeDomainOverride: "PLAYER_ITEM_LOAD_FAILED"
1124
+ )
1125
+ }
1126
+
1127
+ /// Shared dispatch path for `handleCurrentItemFailedToLoad` +
1128
+ /// `handlePlayerItemFailedToPlayToEndTime`. Encapsulates: (1)
1129
+ /// classifier, (2) qid-fallback to fresh UUID for raw items, (3)
1130
+ /// (queueItemId, code) dedup, (4) auto-retry path with dedup
1131
+ /// clear-on-retry, (5) terminal `errorListeners.forEach` +
1132
+ /// emitState(.error). Single chokepoint so the dedup + retry
1133
+ /// contracts can't drift between callers.
1134
+ ///
1135
+ /// `nativeDomainOverride` lets a caller stamp a stable lib-defined
1136
+ /// SCREAMING_SNAKE_CASE marker on the emitted `nativeDomain` (e.g.
1137
+ /// `PLAYER_ITEM_LOAD_FAILED` from the `.failed`-status path) so JS
1138
+ /// consumers can branch on the cause without parsing raw native
1139
+ /// domain strings. Nil preserves the underlying `NSError.domain`.
1140
+ private func dispatchErrorOrRetry(
1141
+ item: AVPlayerItem?,
1142
+ underlying: NSError?,
1143
+ nativeDomainOverride: String? = nil
1144
+ ) {
1145
+ let mapped = PlaybackErrorMapping.classify(underlying)
1146
+ // Fresh UUID fallback per fire (NOT a static sentinel) when
1147
+ // queueItemId is missing — two unrelated items both lacking the
1148
+ // associated-object would otherwise dedup against each other.
1149
+ // Production AVQueueBuilder.makeItem always sets queueItemId; this
1150
+ // path covers raw `makeItemRaw` items (test stubs).
1151
+ let qid = item?.queueItemId ?? UUID().uuidString
1152
+ if self.lastErrorQueueItemId == qid && self.lastErrorCode == mapped { return }
1153
+ self.lastErrorQueueItemId = qid
1154
+ self.lastErrorCode = mapped
1155
+ // Auto-retry path: transient errors with retries left schedule
1156
+ // a delayed rebuild. Skip JS emit + state.error transition until
1157
+ // retries exhausted.
1158
+ if PlaybackErrorMapping.isTransient(mapped),
1159
+ let qidReal = item?.queueItemId,
1160
+ (self.retryAttemptsRemaining[qidReal] ?? 0) > 0 {
1161
+ self.retryAttemptsRemaining[qidReal] = self.retryAttemptsRemaining[qidReal]! - 1
1162
+ // Clear dedup so the retry-then-fail (if any) fires fresh.
1163
+ self.lastErrorQueueItemId = nil
1164
+ self.lastErrorCode = nil
1165
+ let backoff = self.effectiveRetryBackoffMs()
1166
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(backoff)) {
1167
+ [weak self] in self?.retryFailedItem(queueItemId: qidReal)
1168
+ }
1169
+ return
1170
+ }
1171
+ // Surface the failing item's queueItemId + URL so consumers can
1172
+ // correlate the error to the specific track even when
1173
+ // AVQueuePlayer chain-advances past it before the JS event fires.
1174
+ // Reuse `qid` (which is the lib-generated UUID fallback for raw
1175
+ // items) so the JS-visible field matches the dedup key.
1176
+ let failedQid = item?.queueItemId ?? qid
1177
+ let failedUrl = (item?.asset as? AVURLAsset)?.url.absoluteString ?? ""
1178
+ let err = self.buildPlaybackError(
1179
+ underlying,
1180
+ mapped: mapped,
1181
+ queueItemId: failedQid,
1182
+ url: failedUrl,
1183
+ nativeDomainOverride: nativeDomainOverride
1184
+ )
1185
+ self.errorListeners.forEach { $0(err) }
1186
+ self.emitState(.error, reason: .error)
1187
+ }
1188
+
1189
+ private func tearDownGaplessObservers() {
1190
+ currentItemObserver?.invalidate()
1191
+ currentItemObserver = nil
1192
+ currentItemStatusObserver?.invalidate()
1193
+ currentItemStatusObserver = nil
1194
+ tearDownBufferObservers()
1195
+ }
1196
+
1197
+ /// Re-arm the initial-buffer leniency: the next `.readyToPlay`
1198
+ /// flips stalling back to false. Called from `fullRebuildPlayerQueue`
1199
+ /// (central choke point for "the currentItem is being replaced by
1200
+ /// a fresh AVPlayerItem") and from `removeFromQueue` when the
1201
+ /// currentItem is dropped and AVQueuePlayer auto-advances to an
1202
+ /// item that may not yet be buffered.
1203
+ ///
1204
+ /// The `\.currentItem` KVO chain already installed in `configure`
1205
+ /// handles attaching the status observer once the new currentItem
1206
+ /// is in place — we don't attach it synchronously here to avoid
1207
+ /// racing against the caller's in-flight `player.insert(...)`
1208
+ /// sequence.
1209
+ internal func rearmGaplessFlip() {
1210
+ guard let player = self.player else { return }
1211
+ self.gaplessFlipArmed = true
1212
+ player.automaticallyWaitsToMinimizeStalling = true
1213
+ }
1214
+
1215
+ // MARK: - Queue mutation
1216
+ //
1217
+ // All mutations operate on two parallel structures:
1218
+ // 1. `self.tracks` — the authoritative ordered TrackItem list.
1219
+ // AVQueuePlayer consumes items forward-only so skip-backward
1220
+ // rebuilds the player's queue from this list.
1221
+ // 2. The AVQueuePlayer instance's internal queue — mirrors
1222
+ // `self.tracks` starting from `currentTrackIndex` onward.
1223
+ // Surgical insert/remove calls keep it in sync without
1224
+ // tearing down the currently-playing item when possible.
1225
+ //
1226
+ // Thread safety: every mutation body hops to the main thread via
1227
+ // `onMain`. AVFoundation is not documented as thread-safe for
1228
+ // AVQueuePlayer mutations, and Apple's samples always mutate from
1229
+ // main. The hop also serialises concurrent JS calls — two back-
1230
+ // to-back `addToQueue` + `removeFromQueue` invocations cannot
1231
+ // interleave because DispatchQueue.main is serial by definition.
1232
+
1233
+ /// Serialise a mutation body on the main thread and return a
1234
+ /// Promise-compatible awaitable. Centralising here keeps the
1235
+ /// AVQueuePlayer-mutation rule (main-thread-only) enforceable.
1236
+ /// `@MainActor` on the closure parameter lets the body access
1237
+ /// MainActor-isolated AVFoundation APIs (AVPlayer.play / pause /
1238
+ /// rate / etc.) without per-call-site warnings; the body really
1239
+ /// does run on main because the dispatch destination is
1240
+ /// `DispatchQueue.main`.
1241
+ private func onMain(_ body: @escaping @MainActor () -> Void) async {
1242
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
1243
+ DispatchQueue.main.async {
1244
+ MainActor.assumeIsolated {
1245
+ body()
1246
+ }
1247
+ cont.resume()
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ /// Throwing variant of `onMain` — propagates a Swift `throw` from
1253
+ /// the body up through the await so callers wrapping the call in
1254
+ /// a `Promise<Void>.async` see the JS Promise reject with the
1255
+ /// thrown error's message via the Nitro bridge.
1256
+ private func onMainThrowing(_ body: @escaping @MainActor () throws -> Void) async throws {
1257
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
1258
+ DispatchQueue.main.async {
1259
+ MainActor.assumeIsolated {
1260
+ do {
1261
+ try body()
1262
+ cont.resume()
1263
+ } catch {
1264
+ cont.resume(throwing: error)
1265
+ }
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ /// Raise `isMutatingPlayerQueue` for the duration of `body`,
1272
+ /// restoring the prior value on exit (so nested calls stay true
1273
+ /// throughout the outer mutation). Used to suppress spurious
1274
+ /// `onQueueEnd` / `onTrackChange` fires from the `\.currentItem`
1275
+ /// KVO during the `removeAllItems` → `insert` intermediate nil
1276
+ /// window. Must be called on main thread.
1277
+ private func performingMutation(_ body: () -> Void) {
1278
+ let wasAlready = self.isMutatingPlayerQueue
1279
+ self.isMutatingPlayerQueue = true
1280
+ defer { self.isMutatingPlayerQueue = wasAlready }
1281
+ body()
1282
+ }
1283
+
1284
+ func setQueue(tracks: [TrackItem], startAtIndex: Double?) throws -> Promise<Void> {
1285
+ return Promise<Void>.async {
1286
+ // Cast-route short-circuit: when a remote session is active,
1287
+ // push the queue to the receiver via `CastTransportRouter`. Local
1288
+ // `queueState` + `currentTrackIndex` are still updated so JS
1289
+ // reads (`getQueue` / `getCurrentTrackIndex`) reflect the
1290
+ // user-visible queue while the receiver is authoritative for
1291
+ // playback. Mirrors Android 7B.2 (4/5)'s pattern. Remote-load
1292
+ // failures fall through to the local path.
1293
+ let resolvedStart: Int
1294
+ if tracks.isEmpty {
1295
+ resolvedStart = -1
1296
+ } else {
1297
+ let requested = startAtIndex.map { Int($0) } ?? 0
1298
+ resolvedStart = max(0, min(requested, tracks.count - 1))
1299
+ }
1300
+ let castItems = tracks.compactMap { CastMediaItem.from(track: $0) }
1301
+ // Skip the cast route if EVERY track had an unparseable url
1302
+ // (`from(track:)` returned nil). Empty queues mean nothing to
1303
+ // play; let the local path handle the clear-queue case.
1304
+ let canRouteToCast = !tracks.isEmpty && castItems.count == tracks.count
1305
+ var castItemIds: [Int]? = nil
1306
+ if canRouteToCast {
1307
+ do {
1308
+ // Replacing the whole queue starts the new queue on the receiver
1309
+ // (distinct from `handoffCurrentPlaybackToCast`, which preserves the
1310
+ // in-flight play/pause state). The local engine is paused during cast,
1311
+ // so its state can't stand in for the receiver's here.
1312
+ castItemIds = try await CastTransportRouter.routeSetQueue(
1313
+ tracks: castItems,
1314
+ startIndex: max(0, resolvedStart),
1315
+ startPositionMs: 0,
1316
+ playWhenReady: true
1317
+ )
1318
+ } catch {
1319
+ // Receiver-side load failed — fall through to local so the
1320
+ // user isn't stuck.
1321
+ castItemIds = nil
1322
+ }
1323
+ }
1324
+ if castItemIds != nil {
1325
+ // Mirror the new queue locally so JS reads stay accurate
1326
+ // (`getQueue`/`getCurrentTrackIndex`) AND so the local engine
1327
+ // is ready to take over with the right items when cast
1328
+ // disconnects. `syncPlayerQueue(preserveCurrent: false)`
1329
+ // rebuilds the AVQueuePlayer items + arms initial-buffer
1330
+ // leniency for the resolved start track. The local engine
1331
+ // is already paused (the handoff on the cast-active transition
1332
+ // silenced it); the rebuild stays paused.
1333
+ await self.onMain {
1334
+ self.performingMutation {
1335
+ guard self.engine != nil else { return }
1336
+ _ = self.queueState.replaceAll(tracks)
1337
+ self.currentTrackIndex = resolvedStart
1338
+ self.pendingTrackChangeReason = .queueReplaced
1339
+ self.lastErrorQueueItemId = nil
1340
+ self.lastErrorCode = nil
1341
+ self.syncPlayerQueue(preserveCurrent: false)
1342
+ }
1343
+ // Seed the start track's lockscreen metadata immediately so the
1344
+ // surface doesn't wait for the first receiver status. The current
1345
+ // track is resolved from the receiver's absolute queue index, not
1346
+ // the itemIds, so no itemId map is needed here.
1347
+ self.castEventBridge.seedStartMetadata(startIndex: resolvedStart)
1348
+ self.rescheduleLookahead()
1349
+ self.recomputeCapabilities()
1350
+ }
1351
+ return
1352
+ }
1353
+ await self.onMain {
1354
+ self.performingMutation {
1355
+ // `self.engine` is the canonical "lib initialised" sentinel.
1356
+ // `self.player` is the gapless engine's AVQueuePlayer and is
1357
+ // nil under CrossfadeEngine — gating on it here would silently
1358
+ // no-op every queue mutation made while in crossfade mode.
1359
+ guard self.engine != nil else { return }
1360
+ let newIds = self.queueState.replaceAll(tracks)
1361
+ // Clamp the consumer-supplied start index into the queue
1362
+ // bounds. Empty queue ignores the parameter; nil / NaN /
1363
+ // negative / out-of-range all coerce to 0.
1364
+ let resolvedStart: Int
1365
+ if tracks.isEmpty {
1366
+ resolvedStart = -1
1367
+ } else {
1368
+ let requested = startAtIndex.map { Int($0) } ?? 0
1369
+ resolvedStart = max(0, min(requested, tracks.count - 1))
1370
+ }
1371
+ self.currentTrackIndex = resolvedStart
1372
+ self.pendingTrackChangeReason = .queueReplaced
1373
+ // Fresh queue → fresh dedup state. Without this an error on
1374
+ // the prior queue's last item could suppress an identical
1375
+ // coded error on the new queue's first item.
1376
+ self.lastErrorQueueItemId = nil
1377
+ self.lastErrorCode = nil
1378
+ // Reset auto-retry counters: new queueItemIds → new budget
1379
+ // entries. The retry budget is per-queue-position; old
1380
+ // entries from the prior queue are gone with the prior
1381
+ // queueItemIds.
1382
+ let attempts = self.effectiveAutoRetries()
1383
+ self.retryAttemptsRemaining = Dictionary(
1384
+ uniqueKeysWithValues: newIds.map { ($0, attempts) }
1385
+ )
1386
+ // Rearm happens inside `fullRebuildPlayerQueue` (called via
1387
+ // `syncPlayerQueue(preserveCurrent: false)`) — the resolved
1388
+ // start track gets initial-buffer leniency automatically.
1389
+ self.syncPlayerQueue(preserveCurrent: false)
1390
+ }
1391
+ self.rescheduleLookahead()
1392
+ self.recomputeCapabilities()
1393
+ }
1394
+ }
1395
+ }
1396
+
1397
+ /// Adding to an empty queue (cur == -1) leaves cur at -1 and leaves
1398
+ /// the player empty — consumer must call `skipToIndex(0)` to start
1399
+ /// playback. This avoids implicit "start playing" surprises on
1400
+ /// `addToQueue`. Non-empty queue with
1401
+ /// insertion strictly after cur preserves the currently-playing
1402
+ /// item surgically. Insertion before/at cur shifts cur forward;
1403
+ /// since AVQueuePlayer holds only cur-onward items, the player
1404
+ /// queue is unchanged by before-cur insertions (no playback gap).
1405
+ func addToQueue(tracks newTracks: [TrackItem], insertBefore: Double?) throws -> Promise<Void> {
1406
+ return Promise<Void>.async {
1407
+ // Mutate local (the phone's source-of-truth queue) and capture the
1408
+ // resolved insert index against the pre-mutation queue, then mirror
1409
+ // the insert to the receiver. Index resolution to receiver itemIds
1410
+ // happens inside the cast session via GCKMediaQueue.
1411
+ let insertAt: Int = self.onMainSync {
1412
+ let at = QueueMutationArithmetic.clampInsertBefore(
1413
+ insertBefore: insertBefore.flatMap { $0.isFinite ? Int($0) : nil },
1414
+ trackCount: self.tracks.count
1415
+ )
1416
+ self.performingMutation {
1417
+ self.addToQueueBody(newTracks: newTracks, insertBefore: insertBefore)
1418
+ }
1419
+ self.rescheduleLookahead()
1420
+ self.recomputeCapabilities()
1421
+ return at
1422
+ }
1423
+ // Mirror only when every new track is castable. A dropped item would
1424
+ // shift the receiver queue out of alignment with the local index space
1425
+ // that later mutations (remove/move) resolve against, so a partial set
1426
+ // is not routed at all — the same all-or-nothing rule `setQueue` applies.
1427
+ let castItems = newTracks.compactMap { CastMediaItem.from(track: $0) }
1428
+ if !castItems.isEmpty, castItems.count == newTracks.count {
1429
+ _ = await CastTransportRouter.routeAddToQueue(items: castItems, beforeIndex: insertAt)
1430
+ }
1431
+ }
1432
+ }
1433
+
1434
+ private func addToQueueBody(newTracks: [TrackItem], insertBefore: Double?) {
1435
+ guard !newTracks.isEmpty, let engine = self.engine else { return }
1436
+
1437
+ // Non-finite `insertBefore` is dropped (treated as append) so
1438
+ // `Int($0)` never traps on NaN / ±Infinity. Mirrors Android's
1439
+ // `takeIf { it.isFinite() }?.toInt()` sanitisation at the
1440
+ // mutation boundary.
1441
+ let at = QueueMutationArithmetic.clampInsertBefore(
1442
+ insertBefore: insertBefore.flatMap { $0.isFinite ? Int($0) : nil },
1443
+ trackCount: self.tracks.count
1444
+ )
1445
+
1446
+ let newIds = self.queueState.insert(newTracks, at: at)
1447
+ // Initialise retry-budget entries for the new items so they have
1448
+ // an auto-retry budget when (if) they hit a transient error.
1449
+ let attempts = self.effectiveAutoRetries()
1450
+ for id in newIds {
1451
+ self.retryAttemptsRemaining[id] = attempts
1452
+ }
1453
+ // Queue mutation regenerates the shuffle order. Documented
1454
+ // trade-off ("shuffle jumps" on mid-playback adds) over preserving
1455
+ // partial ordering. No-op when shuffle is disabled.
1456
+
1457
+ let oldCur = self.currentTrackIndex
1458
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
1459
+ currentIndex: oldCur, insertAt: at, count: newTracks.count
1460
+ )
1461
+
1462
+ if oldCur < 0 {
1463
+ // Queue was empty — player stays empty until skipToIndex.
1464
+ return
1465
+ }
1466
+
1467
+ if at <= oldCur {
1468
+ // Insertion at/before cur: player queue (cur-onward) is
1469
+ // unchanged; only cur shifted forward (already applied via
1470
+ // `currentIndexAfterAdd` above).
1471
+ return
1472
+ }
1473
+
1474
+ // Insertion strictly after cur: surgical insert preserves the
1475
+ // currently-playing item and any preloaded look-ahead state.
1476
+ // Anchor on the track immediately before the insertion point,
1477
+ // matched by lib-generated identity so it resolves in either
1478
+ // engine's live queue (cur-onward for GaplessEngine, full queue
1479
+ // for CrossfadeEngine). `at > oldCur` here, so `at - 1` is at or
1480
+ // after the current item and therefore present in both.
1481
+ let existing = engine.allMediaItems
1482
+ let anchorId: String? = (at - 1 >= 0 && at - 1 < self.queueItemIds.count)
1483
+ ? self.queueItemIds[at - 1] : nil
1484
+ let afterItem: AVPlayerItem? =
1485
+ existing.first { $0.queueItemId != nil && $0.queueItemId == anchorId }
1486
+ ?? existing.last
1487
+
1488
+ let newItems = AVQueueBuilder.buildPlayerItems(
1489
+ tracks: newTracks, queueItemIds: newIds,
1490
+ config: self.config, cache: self.lookaheadCache)
1491
+ let ok = engine.insertItems(newItems, after: afterItem)
1492
+ if !ok {
1493
+ // Surgical insert failed — recover by rebuilding the tail
1494
+ // so `self.tracks` and the engine queue cannot diverge.
1495
+ self.syncPlayerQueue(preserveCurrent: true)
1496
+ }
1497
+ }
1498
+
1499
+ func removeFromQueue(indices: [Double]) throws -> Promise<Void> {
1500
+ return Promise<Void>.async {
1501
+ // Capture the sanitised indices against the pre-mutation queue, mutate
1502
+ // local, then mirror the removal to the receiver (resolved to stable
1503
+ // itemIds inside the cast session before removing).
1504
+ let castIndices: [Int] = self.onMainSync {
1505
+ // Exclude the current index — the currently-playing track is
1506
+ // pinned on the receiver too, matching the local body below.
1507
+ let cur = self.currentTrackIndex
1508
+ let sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
1509
+ rawIndices: indices.map { Int($0) }, trackCount: self.tracks.count
1510
+ ).filter { $0 != cur }
1511
+ self.performingMutation {
1512
+ self.removeFromQueueBody(indices: indices)
1513
+ }
1514
+ self.rescheduleLookahead()
1515
+ self.recomputeCapabilities()
1516
+ return sanitised
1517
+ }
1518
+ if !castIndices.isEmpty {
1519
+ _ = await CastTransportRouter.routeRemoveFromQueue(indices: castIndices)
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ private func removeFromQueueBody(indices: [Double]) {
1525
+ guard !indices.isEmpty, let engine = self.engine else { return }
1526
+ // `removeIds` below force-subscripts `queueItemIds`; this asserts the
1527
+ // parallel-array invariant the sanitiser's in-range output relies on.
1528
+ assertQueueIdsInvariant("removeFromQueueBody")
1529
+
1530
+ var sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
1531
+ rawIndices: indices.map { Int($0) },
1532
+ trackCount: self.tracks.count
1533
+ )
1534
+ // The currently-playing track is pinned: it cannot be removed via
1535
+ // removeFromQueue (a consumer that wants it gone skips off it first,
1536
+ // then removes it). Drop the current index from the set — a request
1537
+ // to remove only the current track is a no-op; a mixed request drops
1538
+ // the other tracks and keeps the current one.
1539
+ sanitised.removeAll { $0 == self.currentTrackIndex }
1540
+ guard !sanitised.isEmpty else { return }
1541
+
1542
+ let trackCountBefore = self.tracks.count
1543
+ let oldCur = self.currentTrackIndex
1544
+
1545
+ // Match the AVPlayerItems to drop by their lib-generated identity
1546
+ // rather than by index offset, so the removal is engine-agnostic:
1547
+ // GaplessEngine exposes only the cur-onward slice (consumed items
1548
+ // are gone), while CrossfadeEngine exposes the full queue. Captured
1549
+ // before the `queueState.remove` loop below mutates the id array.
1550
+ let removeIds = Set(sanitised.map { self.queueItemIds[$0] })
1551
+ let itemsToRemove = engine.allMediaItems.filter { item in
1552
+ guard let id = item.queueItemId else { return false }
1553
+ return removeIds.contains(id)
1554
+ }
1555
+
1556
+ // Iterate in descending order so earlier indices stay valid as
1557
+ // each removal shifts the remainder. Drop the matching
1558
+ // retry-budget entry so the map doesn't grow stale.
1559
+ for i in sanitised.reversed() {
1560
+ let removedId = self.queueState.remove(at: i)
1561
+ self.retryAttemptsRemaining.removeValue(forKey: removedId)
1562
+ }
1563
+ // Queue mutation regenerates the shuffle order. No-op when
1564
+ // shuffle is disabled.
1565
+
1566
+ // The current track is never in the removed set, so it keeps playing
1567
+ // and its identity is unchanged; only its index shifts down by the
1568
+ // number of removed earlier tracks.
1569
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
1570
+ currentIndex: oldCur,
1571
+ sanitisedIndices: sanitised,
1572
+ trackCountBefore: trackCountBefore
1573
+ )
1574
+
1575
+ // The currentItem is untouched (never in `itemsToRemove`); removing
1576
+ // only later/earlier items leaves it playing in place.
1577
+ for item in itemsToRemove {
1578
+ engine.remove(item)
1579
+ }
1580
+ }
1581
+
1582
+ func moveInQueue(fromIndex: Double, toIndex: Double) throws -> Promise<Void> {
1583
+ return Promise<Void>.async {
1584
+ // Capture the validated from/to against the pre-mutation queue, mutate
1585
+ // local, then mirror the reorder to the receiver.
1586
+ let move: (from: Int, to: Int)? = self.onMainSync {
1587
+ let from = Int(fromIndex)
1588
+ let to = Int(toIndex)
1589
+ let valid = from >= 0 && from < self.tracks.count
1590
+ && to >= 0 && to < self.tracks.count && from != to
1591
+ && from != self.currentTrackIndex
1592
+ self.performingMutation {
1593
+ self.moveInQueueBody(fromIndex: fromIndex, toIndex: toIndex)
1594
+ }
1595
+ self.rescheduleLookahead()
1596
+ self.recomputeCapabilities()
1597
+ return valid ? (from, to) : nil
1598
+ }
1599
+ if let move = move {
1600
+ _ = await CastTransportRouter.routeMoveInQueue(fromIndex: move.from, toIndex: move.to)
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ private func moveInQueueBody(fromIndex: Double, toIndex: Double) {
1606
+ guard self.engine != nil else { return }
1607
+ let from = Int(fromIndex)
1608
+ let to = Int(toIndex)
1609
+ // The currently-playing track cannot be reordered — only the tracks
1610
+ // around it move. Reordering the current item would force a full
1611
+ // rebuild that restarts the active track.
1612
+ guard from >= 0, from < self.tracks.count,
1613
+ to >= 0, to < self.tracks.count,
1614
+ from != to,
1615
+ from != self.currentTrackIndex else { return }
1616
+
1617
+ let oldCur = self.currentTrackIndex
1618
+ self.queueState.move(from: from, to: to)
1619
+ // Queue mutation regenerates the shuffle order. No-op when
1620
+ // shuffle is disabled.
1621
+
1622
+ self.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
1623
+ currentIndex: oldCur, fromIndex: from, toIndex: to
1624
+ )
1625
+ let newCur = self.currentTrackIndex
1626
+
1627
+ // Decide engine-queue adjustment:
1628
+ // * cur unchanged (oldCur == newCur) AND cur track wasn't
1629
+ // moved: engine queue is stale only in the tail; rebuild
1630
+ // tail while preserving currentItem.
1631
+ // * cur moved (from == oldCur) OR cur's physical position
1632
+ // shifted: full rebuild. This restarts the currently-
1633
+ // playing track — unavoidable because AVQueuePlayer
1634
+ // cannot re-order arbitrary items around its play head.
1635
+ let preserveCurrent = (from != oldCur) && (newCur == oldCur)
1636
+ // performingMutation so the crossfade engine's setItems →
1637
+ // engineDidTransitionTrack doesn't clobber the move-computed
1638
+ // currentTrackIndex with the rebuilt slice's engine index 0 (mirrors the
1639
+ // setQueue paths, which already mutate under this guard).
1640
+ self.performingMutation {
1641
+ self.syncPlayerQueue(preserveCurrent: preserveCurrent)
1642
+ }
1643
+ }
1644
+
1645
+ func clearQueue() throws -> Promise<Void> {
1646
+ return Promise<Void>.async {
1647
+ await self.onMain {
1648
+ self.performingMutation {
1649
+ self.engine?.removeAllItems()
1650
+ self.queueState.clear()
1651
+ self.currentTrackIndex = -1
1652
+ self.currentTrackSource = nil
1653
+ // No rearm here — the next `setQueue` goes through
1654
+ // `fullRebuildPlayerQueue` which rearms before inserts,
1655
+ // so the clean-slate initial-buffer phase is preserved.
1656
+ }
1657
+ // `performingMutation` suppresses the `\.currentItem` → nil
1658
+ // KVO fire; emit a synthetic trackChange here so JS hooks
1659
+ // (e.g. `useActiveTrack`) clear their (track, index) snapshot.
1660
+ // Mirrors Android `clearQueueInternal` which fires
1661
+ // `(null, -1, QUEUE_REPLACED)` via Media3's onMediaItemTransition
1662
+ // → `handleMediaItemTransition`. Reset `lastEmittedQueueItemId`
1663
+ // so a subsequent `setQueue` re-emits cleanly via the dedup
1664
+ // gate at `handleCurrentItemDidChange`.
1665
+ self.lastEmittedQueueItemId = nil
1666
+ self.lastErrorQueueItemId = nil
1667
+ self.lastErrorCode = nil
1668
+ self.retryAttemptsRemaining.removeAll()
1669
+ self.trackChangeListeners.forEach { $0(nil, Double(-1), .queueReplaced, nil) }
1670
+ self.nowPlayingInfo.refreshAll()
1671
+ self.artworkResolver.cancelInFlight()
1672
+ // Drop any cached now-playing format + emit null so JS-side
1673
+ // consumers of `onNowPlayingFormatChange` see the queue-cleared
1674
+ // state without waiting for the engine's `currentMediaItem` to
1675
+ // settle (it can briefly retain the last item post-removeAll).
1676
+ self.refreshNowPlayingFormatForActiveItem()
1677
+ self.rescheduleLookahead()
1678
+ self.recomputeCapabilities()
1679
+ // The queue is empty now → settle buffer state + fully-buffered to their
1680
+ // empty defaults and emit the transition. The `\.currentItem → nil` KVO
1681
+ // is suppressed under `performingMutation`, so recompute explicitly.
1682
+ self.hasStartedPlaying = false
1683
+ self.wantsToPlay = false
1684
+ self.recomputeBufferState()
1685
+ self.recomputeFullyBuffered()
1686
+ }
1687
+ // Mirror the clear to the receiver (removes every receiver queue item).
1688
+ _ = await CastTransportRouter.routeClearQueue()
1689
+ }
1690
+ }
1691
+
1692
+ func getQueue() throws -> [TrackItem] {
1693
+ return tracks
1694
+ }
1695
+
1696
+ /// Sync the engine's queue with `self.tracks[currentTrackIndex...]`.
1697
+ ///
1698
+ /// When `preserveCurrent` is true AND the engine has a currentItem,
1699
+ /// that item is kept in place and only the tail (items after
1700
+ /// current) is rebuilt — avoids a playback restart of the current
1701
+ /// track. Used for mutations that leave the currently-playing
1702
+ /// track untouched (e.g. remove-after-cur, move-entirely-after-cur).
1703
+ ///
1704
+ /// When false (or when there is no currentItem), does a full
1705
+ /// rebuild. The consumer track restarts from position 0.
1706
+ internal func syncPlayerQueue(preserveCurrent: Bool) {
1707
+ assertQueueIdsInvariant("syncPlayerQueue")
1708
+ guard let engine = self.engine else { return }
1709
+ guard self.currentTrackIndex >= 0,
1710
+ self.currentTrackIndex < self.tracks.count else {
1711
+ engine.removeAllItems()
1712
+ return
1713
+ }
1714
+
1715
+ // Build the full tail post-currentItem.
1716
+ let tailStart = self.currentTrackIndex + 1
1717
+ let tailEnd = self.tracks.count
1718
+ let tailTracks = Array(self.tracks[tailStart ..< tailEnd])
1719
+ let tailIds = Array(self.queueItemIds[tailStart ..< tailEnd])
1720
+ let tailItems = AVQueueBuilder.buildPlayerItems(
1721
+ tracks: tailTracks, queueItemIds: tailIds,
1722
+ config: self.config, cache: self.lookaheadCache)
1723
+
1724
+ if preserveCurrent, let currentItem = engine.currentMediaItem {
1725
+ for item in engine.allMediaItems where item !== currentItem {
1726
+ engine.remove(item)
1727
+ }
1728
+ let ok = engine.insertItems(tailItems, after: currentItem)
1729
+ if !ok {
1730
+ // Couldn't insert into the live queue — fall through to
1731
+ // full rebuild so authoritative state + player converge.
1732
+ return self.fullRebuildPlayerQueue()
1733
+ }
1734
+ return
1735
+ }
1736
+
1737
+ self.fullRebuildPlayerQueue()
1738
+ }
1739
+
1740
+ /// Drain + re-install the full `tracks[currentTrackIndex...]`
1741
+ /// slice on the active engine. The rate save/restore that used
1742
+ /// to live here is now internal to `engine.setItems`. Rearms
1743
+ /// gapless-flip stalling BEFORE the install so the fresh
1744
+ /// currentItem gets initial-buffer leniency until its
1745
+ /// `\.readyToPlay` flips stalling off via the KVO chain.
1746
+ private func fullRebuildPlayerQueue() {
1747
+ assertQueueIdsInvariant("fullRebuildPlayerQueue")
1748
+ guard let engine = self.engine else { return }
1749
+ guard self.currentTrackIndex >= 0,
1750
+ self.currentTrackIndex < self.tracks.count else {
1751
+ engine.removeAllItems()
1752
+ return
1753
+ }
1754
+ rearmGaplessFlip()
1755
+ // Rebuild the full `tracks[cur...]` slice. AVURLAsset construction
1756
+ // cost is amortised by the lookahead-cache prefetcher for the
1757
+ // upcoming window; items beyond that are constructed lazily
1758
+ // (assets don't load until the engine queries them).
1759
+ let cur = self.currentTrackIndex
1760
+ let slice = Array(self.tracks[cur ..< self.tracks.count])
1761
+ let sliceIds = Array(self.queueItemIds[cur ..< self.tracks.count])
1762
+ let items = AVQueueBuilder.buildPlayerItems(
1763
+ tracks: slice, queueItemIds: sliceIds,
1764
+ config: self.config, cache: self.lookaheadCache)
1765
+ engine.setItems(items, startIndex: 0, startPositionSeconds: 0)
1766
+ }
1767
+
1768
+
1769
+ // MARK: - Transport — pass-through to AVQueuePlayer.
1770
+
1771
+ func play() throws -> Promise<Void> {
1772
+ return Promise<Void>.async {
1773
+ if CastTransportRouter.routePlay() { return }
1774
+ await self.onMain {
1775
+ // Stuck-recovery: if the user re-taps play after an
1776
+ // exhausted-retries error, give one more shot. Reset
1777
+ // counter to 1 (single shot, not full autoRetries — each
1778
+ // user tap = one attempt for predictable UX) and rebuild.
1779
+ if self.lastReportedState == .error,
1780
+ self.currentTrackIndex >= 0,
1781
+ self.currentTrackIndex < self.queueItemIds.count {
1782
+ let qid = self.queueItemIds[self.currentTrackIndex]
1783
+ if (self.retryAttemptsRemaining[qid] ?? 0) == 0 {
1784
+ self.retryAttemptsRemaining[qid] = 1
1785
+ self.lastErrorQueueItemId = nil
1786
+ self.lastErrorCode = nil
1787
+ if self.player != nil {
1788
+ self.performingMutation { self.fullRebuildPlayerQueue() }
1789
+ }
1790
+ }
1791
+ }
1792
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1793
+ self.wantsToPlay = true
1794
+ self.engine?.play()
1795
+ self.recomputeBufferState()
1796
+ }
1797
+ }
1798
+ }
1799
+
1800
+ func retry() throws -> Promise<Void> {
1801
+ return Promise<Void>.async {
1802
+ // During cast the receiver owns playback — forward a play so a failed
1803
+ // receiver item re-attempts.
1804
+ if CastTransportRouter.routePlay() { return }
1805
+ await self.onMain {
1806
+ // Local recovery: only meaningful for a track that errored after its
1807
+ // automatic retries were exhausted. Give it one fresh attempt — reset
1808
+ // the per-track retry budget, rebuild the failed item, and resume.
1809
+ guard self.lastReportedState == .error,
1810
+ self.currentTrackIndex >= 0,
1811
+ self.currentTrackIndex < self.queueItemIds.count else { return }
1812
+ let qid = self.queueItemIds[self.currentTrackIndex]
1813
+ // Capture the pre-failure position so a mid-track failure resumes where
1814
+ // it stopped (the rebuild below reloads the item from 0). Matches
1815
+ // Android's retryFailedItem position-restore.
1816
+ let resumePos = self.engine?.currentPositionSeconds ?? 0
1817
+ self.retryAttemptsRemaining[qid] = 1
1818
+ self.lastErrorQueueItemId = nil
1819
+ self.lastErrorCode = nil
1820
+ if self.player != nil {
1821
+ self.performingMutation { self.fullRebuildPlayerQueue() }
1822
+ }
1823
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1824
+ self.wantsToPlay = true
1825
+ self.engine?.play()
1826
+ if resumePos.isFinite, resumePos > 0 {
1827
+ _ = try? self.seekTo(position: resumePos)
1828
+ }
1829
+ self.recomputeBufferState()
1830
+ }
1831
+ }
1832
+ }
1833
+
1834
+ func pause() throws -> Promise<Void> {
1835
+ return Promise<Void>.async {
1836
+ if CastTransportRouter.routePause() { return }
1837
+ await self.onMain {
1838
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1839
+ self.wantsToPlay = false
1840
+ self.engine?.pause()
1841
+ self.recomputeBufferState()
1842
+ }
1843
+ }
1844
+ }
1845
+
1846
+ /// "Stop" = pause + seek to start of current item. Does NOT clear
1847
+ /// the queue — consumers wanting a full reset call clearQueue().
1848
+ func stop() throws -> Promise<Void> {
1849
+ return Promise<Void>.async {
1850
+ if CastTransportRouter.routeStop() { return }
1851
+ await self.onMain {
1852
+ guard let engine = self.engine else { return }
1853
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1854
+ self.wantsToPlay = false
1855
+ self.hasStartedPlaying = false
1856
+ engine.stop()
1857
+ self.recomputeBufferState()
1858
+ }
1859
+ }
1860
+ }
1861
+
1862
+ /// Seek within the current track. Position in seconds; negative
1863
+ /// values clamp to 0, values past the duration clamp to duration.
1864
+ func seekTo(position: Double) throws -> Promise<Void> {
1865
+ return Promise<Void>.async {
1866
+ // Match Android (`TrackPlayer.kt` seekToInternal): non-finite
1867
+ // (NaN / ±Infinity) inputs are silent no-ops. See
1868
+ // `InputGuards.sanitisedSeekPosition` for the rationale.
1869
+ guard let position = InputGuards.sanitisedSeekPosition(position) else { return }
1870
+ if CastTransportRouter.routeSeekTo(positionMs: Int64(position * 1000.0)) { return }
1871
+
1872
+ // Engine-aware seek: under CrossfadeEngine `self.player` is
1873
+ // nil. Without this branch every seek call would silently
1874
+ // no-op once crossfade mode is active, breaking transport
1875
+ // controls + auto-advance test coverage.
1876
+ if self.player == nil {
1877
+ guard let engine = self.engine else { return }
1878
+ await self.onMain {
1879
+ // Seeking replays from a position — no longer at the end of the
1880
+ // queue. (Gapless no-ops at end-of-queue with no current item below.)
1881
+ self.reachedQueueEnd = false
1882
+ let duration = engine.currentDurationSeconds
1883
+ let clamped: Double
1884
+ if position < 0 {
1885
+ clamped = 0
1886
+ } else {
1887
+ clamped = (duration > 0 && position > duration) ? duration : position
1888
+ }
1889
+ let seekTarget = self.clampToBufferedIfEnabled(clamped)
1890
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1891
+ engine.seek(toIndex: self.currentTrackIndex, position: seekTarget)
1892
+ self.milestoneTracker.seek(
1893
+ durationSec: self.effectiveMilestoneDuration(playerDuration: duration),
1894
+ positionSec: seekTarget)
1895
+ self.nowPlayingInfo.refreshPositionAndRate()
1896
+ }
1897
+ return
1898
+ }
1899
+
1900
+ // A drained gapless player (`currentItem == nil`, the end-of-queue state
1901
+ // where `reachedQueueEnd` is set) returns here before seeking, so the
1902
+ // latch persists; a live `currentItem` means the latch was never set, so
1903
+ // no clear is needed on this path.
1904
+ guard let player = self.player, let item = player.currentItem else {
1905
+ return
1906
+ }
1907
+ let itemDuration = CMTimeGetSeconds(item.duration)
1908
+ let clamped: Double
1909
+ if position < 0 {
1910
+ clamped = 0
1911
+ } else {
1912
+ clamped = (itemDuration.isFinite && position > itemDuration) ? itemDuration : position
1913
+ }
1914
+ // Apply the opt-in buffered clamp on top of the 0/duration clamp — caps
1915
+ // the target at the buffered edge when the seek is past what's downloaded.
1916
+ let seekTarget = self.clampToBufferedIfEnabled(clamped)
1917
+ // Stash `.user` reason BEFORE the seek so any state transition
1918
+ // the seek triggers (e.g. playable → buffering on a long jump)
1919
+ // gets the correct origin. Matches the play / pause / stop
1920
+ // pattern; the docstring contract at line 181-187 names all four
1921
+ // transports as `.user` stashers.
1922
+ // Consume jumped-over milestones BEFORE issuing the (async) seek, on
1923
+ // main, so a time-jump progress tick that fires during the seek
1924
+ // can't emit the thresholds the seek skips over.
1925
+ await self.onMain {
1926
+ self.milestoneTracker.seek(
1927
+ durationSec: self.effectiveMilestoneDuration(
1928
+ playerDuration: (itemDuration.isFinite && itemDuration > 0) ? itemDuration : 0),
1929
+ positionSec: seekTarget)
1930
+ }
1931
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
1932
+ let target = CMTime(seconds: seekTarget, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
1933
+ await player.seek(
1934
+ to: target,
1935
+ toleranceBefore: .zero,
1936
+ toleranceAfter: .zero)
1937
+ // Surface the new scrubber position to the lock-screen / control-
1938
+ // center immediately. The KVO state-change emit covers transitions
1939
+ // (e.g. paused → buffering on a long jump) but a same-state seek
1940
+ // won't trigger an emit; without this the lock-screen scrubber sits
1941
+ // at the pre-seek position until the next 10s periodic tick.
1942
+ await self.onMain {
1943
+ self.nowPlayingInfo.refreshPositionAndRate()
1944
+ }
1945
+ }
1946
+ }
1947
+
1948
+ // MARK: - Skip
1949
+ //
1950
+ // AVQueuePlayer is forward-only, so skipToPrevious and skipToIndex
1951
+ // (when target < current) both require rebuilding the player's
1952
+ // queue from the authoritative `self.tracks` list at the new
1953
+ // index. skipToNext is a surgical `player.advanceToNextItem()`
1954
+ // when the next track is already queued; wraps round (via full
1955
+ // rebuild) when `repeatModeState == .queue` and we're past the
1956
+ // end.
1957
+ //
1958
+ // Shuffle (task #19): destructive action `shuffleQueue()`. Mutates
1959
+ // `self.tracks` to a Fisher-Yates permutation, resets
1960
+ // `currentTrackIndex` to 0, resumes playback if was playing, and
1961
+ // returns the new ordering to the consumer. Skip arithmetic walks
1962
+ // linearly across whatever ordering the consumer last installed;
1963
+ // there is no shadow shuffle queue + no shuffle-aware skip
1964
+ // overloads.
1965
+
1966
+ /// `skipToIndex` is an explicit consumer intent — it always honours
1967
+ /// the target index in every repeat mode, including `.queue` (it
1968
+ /// never wraps — the target is absolute). Out-of-range indices
1969
+ /// silently no-op.
1970
+ func skipToIndex(index: Double) throws -> Promise<Void> {
1971
+ return Promise<Void>.async {
1972
+ await self.onMain {
1973
+ // Engine-aware: under CrossfadeEngine `self.player` is nil
1974
+ // by design. Gating the skip on the engine surface lets
1975
+ // mid-play engine swaps + crossfade-mode user skips work.
1976
+ guard self.engine != nil else { return }
1977
+ // See `InputGuards.validQueueIndex` for the rationale —
1978
+ // validates non-finite + out-of-range in Double space so
1979
+ // the subsequent Int conversion is bounded.
1980
+ guard let target = InputGuards.validQueueIndex(
1981
+ index, count: self.tracks.count
1982
+ ) else { return }
1983
+ // During cast, jump the receiver to the target index; its status
1984
+ // echo drives the metadata + active-track update through the
1985
+ // bridge (same as skipToNext/Previous). The local engine stays
1986
+ // paused, so a local index write would desync from the receiver.
1987
+ if CastTransportRouter.routeSkipToIndex(index: target) { return }
1988
+ self.applyNewCurrentIndex(
1989
+ newIndex: target, reason: .userSkipToIndex)
1990
+ }
1991
+ }
1992
+ }
1993
+
1994
+ func skipToNext() throws -> Promise<Void> {
1995
+ return Promise<Void>.async {
1996
+ if CastTransportRouter.routeSkipToNext() { return }
1997
+ await self.onMain {
1998
+ guard self.engine != nil else { return }
1999
+ guard let next = self.computeNextIndex() else { return }
2000
+ self.applyNewCurrentIndex(
2001
+ newIndex: next, reason: .userSkipNext)
2002
+ }
2003
+ }
2004
+ }
2005
+
2006
+ /// Standard skip-previous: go to the previous track if there is one
2007
+ /// (per the arithmetic helper; `.off`/`.track` don't wrap, `.queue`
2008
+ /// wraps). Under `.track` this navigates like `.off` and repeat-one
2009
+ /// persists. Apps that want the Apple-Music-style "tap Previous
2010
+ /// restarts when position > 3s" UX implement that in their button
2011
+ /// handler — the lib provides the primitive (skip if there's a
2012
+ /// track to skip to), not the policy.
2013
+ func skipToPrevious() throws -> Promise<Void> {
2014
+ return Promise<Void>.async {
2015
+ if CastTransportRouter.routeSkipToPrevious() { return }
2016
+ await self.onMain {
2017
+ guard self.engine != nil else { return }
2018
+ guard let prev = self.computePreviousIndex() else { return }
2019
+ self.applyNewCurrentIndex(
2020
+ newIndex: prev, reason: .userSkipPrevious)
2021
+ }
2022
+ }
2023
+ }
2024
+
2025
+ /// Next-track index. Delegates to `QueueSkipArithmetic.computeNext`.
2026
+ /// cur<0 sentinel: "nothing playing yet" → skipToNext is a no-op
2027
+ /// (consumer starts via `setQueue` + `play` or `skipToIndex(0)`).
2028
+ internal func computeNextIndex() -> Int? {
2029
+ return QueueSkipArithmetic.computeNext(
2030
+ trackCount: self.tracks.count,
2031
+ currentIndex: self.currentTrackIndex,
2032
+ repeatMode: repeatModeAsSkipMode())
2033
+ }
2034
+
2035
+ /// Previous-track index. Delegates to `QueueSkipArithmetic.computePrevious`.
2036
+ /// Same cur<0 early-bail as `computeNextIndex`.
2037
+ internal func computePreviousIndex() -> Int? {
2038
+ return QueueSkipArithmetic.computePrevious(
2039
+ trackCount: self.tracks.count,
2040
+ currentIndex: self.currentTrackIndex,
2041
+ repeatMode: repeatModeAsSkipMode())
2042
+ }
2043
+
2044
+ /// Map the Nitrogen-generated `RepeatMode` enum onto the pure-Swift
2045
+ /// `QueueSkipRepeatMode` so `QueueSkipArithmetic` can stay off the
2046
+ /// Swift ↔ C++ interop path (needed so XCTest targets reach the
2047
+ /// arithmetic without C++-interop friction).
2048
+ private func repeatModeAsSkipMode() -> QueueSkipRepeatMode {
2049
+ switch self.repeatModeState {
2050
+ case .off: return .off
2051
+ case .track: return .track
2052
+ case .queue: return .queue
2053
+ }
2054
+ }
2055
+
2056
+ /// Recompute `cachedSkipCapability` from
2057
+ /// authoritative state (`tracks`, `currentTrackIndex`,
2058
+ /// `repeatModeState`) and fire `onSkipCapabilityChange` if either
2059
+ /// flipped.
2060
+ ///
2061
+ /// **Threading invariant:** must be called on main. Reads
2062
+ /// `tracks` / `currentTrackIndex` / `repeatModeState`, all of
2063
+ /// which mutate only on main, so the read is race-free; the
2064
+ /// listener fire iterates a snapshot under `ListenerRegistry`'s
2065
+ /// internal lock so subscriber add/remove during the fire round
2066
+ /// is safe.
2067
+ ///
2068
+ /// **Dedup:** the listener fires only when the (prev, next) tuple
2069
+ /// actually changes. Two back-to-back recompute calls that land on
2070
+ /// the same tuple emit once, not twice. This is what makes it safe
2071
+ /// to scatter `recomputeCapabilities()` calls across every
2072
+ /// mutation site without producing chatty events.
2073
+ ///
2074
+ /// **Hook points** (every site that writes `tracks` /
2075
+ /// `currentTrackIndex` / `repeatModeState`):
2076
+ /// - `setQueue`, `addToQueueBody`, `removeFromQueueBody`,
2077
+ /// `moveInQueueBody`, `clearQueue`
2078
+ /// - `applyNewCurrentIndex` (covers skipToNext / skipToPrevious /
2079
+ /// skipToIndex)
2080
+ /// - `handleCurrentItemDidChange` (covers AVQueuePlayer auto-advance
2081
+ /// that updates `currentTrackIndex` via `matchTrackIndex`)
2082
+ /// - `setRepeatMode`, `shuffleQueue`
2083
+ /// - `destroy` (resets to (false, false) so a final emit lets
2084
+ /// subscribers know the player is gone)
2085
+ internal func recomputeCapabilities() {
2086
+ let mode = repeatModeAsSkipMode()
2087
+ let count = tracks.count
2088
+ let cur = currentTrackIndex
2089
+ let newPrev = QueueSkipArithmetic.canSkipPrevious(
2090
+ trackCount: count, currentIndex: cur, repeatMode: mode)
2091
+ let newNext = QueueSkipArithmetic.canSkipNext(
2092
+ trackCount: count, currentIndex: cur, repeatMode: mode)
2093
+ let current = cachedSkipCapability
2094
+ guard newPrev != current.canSkipPrevious || newNext != current.canSkipNext else {
2095
+ return
2096
+ }
2097
+ let snapshot = SkipCapability(
2098
+ canSkipPrevious: newPrev, canSkipNext: newNext)
2099
+ cachedSkipCapability = snapshot
2100
+ skipCapabilityListeners.forEach { $0(snapshot) }
2101
+ self.remoteCommands.setSkipCapability(
2102
+ canSkipNext: newNext, canSkipPrevious: newPrev
2103
+ )
2104
+ }
2105
+
2106
+ /// Apply a new `currentTrackIndex` and sync the AVQueuePlayer.
2107
+ ///
2108
+ /// **Forward skip** (newIndex > oldIndex) — surgical trim of items
2109
+ /// between current and target + `player.advanceToNextItem()`. Safe
2110
+ /// because `preferredForwardBufferDuration = 0.0` closes the
2111
+ /// chain-advance-while-paused vector and the `didPlayToEndPending`
2112
+ /// resync gate prevents any chain-advance from corrupting
2113
+ /// `currentTrackIndex` even if it did fire.
2114
+ ///
2115
+ /// **Backward skip** (newIndex < oldIndex, including .queue-mode
2116
+ /// wraparound) — full rebuild via `fullRebuildPlayerQueue`.
2117
+ /// AVQueuePlayer is forward-only; backward navigation always
2118
+ /// requires removing all items + re-inserting from the new index.
2119
+ ///
2120
+ /// - `newIndex == oldIndex` → explicit no-op (skipToIndex to the
2121
+ /// current index does not restart it; consumers seek explicitly).
2122
+ /// - out-of-range `newIndex` → silent no-op.
2123
+ ///
2124
+ /// **Void return is intentional.** Silent-return cases here
2125
+ /// (out-of-range, identity skip) are legitimate; track-change /
2126
+ /// queue-end events flow through `handleCurrentItemDidChange`
2127
+ /// driven by the AVQueuePlayer `\.currentItem` KVO, which is the
2128
+ /// source of truth for whether a track actually became current.
2129
+ /// A `Bool` return would be a *prediction* of whether KVO will
2130
+ /// fire — predictions drift, KVO doesn't.
2131
+ private func applyNewCurrentIndex(
2132
+ newIndex: Int, reason: TrackChangeReason
2133
+ ) {
2134
+ guard newIndex >= 0, newIndex < self.tracks.count else { return }
2135
+
2136
+ let oldIndex = self.currentTrackIndex
2137
+ if newIndex == oldIndex {
2138
+ // A same-index skip is an explicit "restart this track" — no longer at
2139
+ // the end of the queue. Clear the latch here because this branch returns
2140
+ // before `dispatchTrackChange` (which is the other clear point), so a
2141
+ // crossfade in-place replay after end-of-queue would otherwise stay
2142
+ // stuck on `.ended`.
2143
+ self.reachedQueueEnd = false
2144
+ // Same-index user skip = "restart this track from 0". Don't
2145
+ // rebuild the queue or fire onTrackChange (the active track
2146
+ // hasn't changed), but do reset the engine's playhead so the
2147
+ // call-site contract is "track N from position 0" regardless
2148
+ // of where the engine currently is — including after a
2149
+ // setPlaybackMode swap that carried position across.
2150
+ //
2151
+ // Engine-aware: GaplessEngine's `allMediaItems` is the
2152
+ // REMAINING queue (consumed items are dropped post auto-
2153
+ // advance), so `engine.seek(toIndex: lib's index, …)`
2154
+ // indexes into the wrong space; reset via the AVQueuePlayer's
2155
+ // currentItem directly. CrossfadeEngine maintains the full
2156
+ // queue 1:1 with `self.tracks`, so the lib-side index is
2157
+ // accepted directly.
2158
+ //
2159
+ // A drained gapless AVQueuePlayer (`currentItem == nil`, so `items()` is
2160
+ // empty) can't be seeked — `actionAtItemEnd = .advance` consumes items as
2161
+ // they finish, so at end-of-queue there's nothing to seek. Rebuild at the
2162
+ // current index to reload the track so the restart intent isn't dropped.
2163
+ // Crossfade (`self.player == nil`) keeps its item on the leading leg, so
2164
+ // the engine seek resets it in place.
2165
+ if let player = self.player, player.currentItem != nil {
2166
+ player.currentItem?.seek(to: .zero, completionHandler: nil)
2167
+ } else if self.player != nil {
2168
+ self.performingMutation { self.fullRebuildPlayerQueue() }
2169
+ } else {
2170
+ self.engine?.seek(toIndex: newIndex, position: 0)
2171
+ }
2172
+ return
2173
+ }
2174
+
2175
+ // Stash reason only now that we know a real transition is
2176
+ // happening. Consumed by `handleCurrentItemDidChange` on the
2177
+ // next non-nil `\.currentItem` KVO fire. Mid-rebuild nil fires
2178
+ // are suppressed by `isMutatingPlayerQueue` so the reason
2179
+ // survives until the terminal currentItem.
2180
+ self.pendingTrackChangeReason = reason
2181
+
2182
+ self.performingMutation {
2183
+ if oldIndex < 0 {
2184
+ // Queue was empty / uninitialised — populate player fresh.
2185
+ self.currentTrackIndex = newIndex
2186
+ fullRebuildPlayerQueue()
2187
+ return
2188
+ }
2189
+
2190
+ self.currentTrackIndex = newIndex
2191
+
2192
+ if self.player == nil {
2193
+ // CrossfadeEngine keeps the full queue 1:1 with `tracks`, so an
2194
+ // absolute indexed seek lands on `newIndex` directly for both
2195
+ // forward and backward navigation. The gapless forward-trim
2196
+ // (`forwardSkipBy`) assumes `allMediaItems` is the remaining
2197
+ // forward-only slice (consumed items dropped) — false for the
2198
+ // crossfade engine, where it would remove the wrong items and
2199
+ // desync the engine's current index from `currentTrackIndex`.
2200
+ self.engine?.seek(toIndex: newIndex, position: 0)
2201
+ } else if newIndex > oldIndex {
2202
+ forwardSkipBy(delta: newIndex - oldIndex)
2203
+ } else {
2204
+ // Backward skip / `.queue`-mode wraparound — full rebuild.
2205
+ // AVQueuePlayer is forward-only; backward navigation has no
2206
+ // surgical alternative.
2207
+ fullRebuildPlayerQueue()
2208
+ }
2209
+ }
2210
+ // Every skip shifts the lookahead window. Reschedule covers all
2211
+ // three skip methods (skipToIndex / Next / Previous all funnel
2212
+ // through here).
2213
+ self.rescheduleLookahead()
2214
+ self.recomputeCapabilities()
2215
+ }
2216
+
2217
+ /// Forward skip via surgical-trim + advanceToNextItem on the
2218
+ /// player's existing items array. Caller holds the surrounding
2219
+ /// `performingMutation` scope; this helper does NOT re-enter it.
2220
+ /// Falls back to `fullRebuildPlayerQueue` when the player snapshot
2221
+ /// is shorter than `delta` (defensive against partial chain-
2222
+ /// advance / error-recovery state).
2223
+ ///
2224
+ /// Workaround: `advanceToNextItem` while paused can chain-advance
2225
+ /// through unbuffered items because AVQueuePlayer interprets
2226
+ /// unresolved durations as "played to end". Mitigated by
2227
+ /// `preferredForwardBufferDuration = 0.0` at item construction +
2228
+ /// the `didPlayToEndPending` resync gate.
2229
+ /// TODO: monitor upstream for fixes — https://developer.apple.com/forums/thread/683744
2230
+ private func forwardSkipBy(delta: Int) {
2231
+ guard let engine = self.engine else { return }
2232
+ let snapshot = engine.allMediaItems
2233
+ guard snapshot.count > delta else {
2234
+ fullRebuildPlayerQueue()
2235
+ return
2236
+ }
2237
+ // Rearm the initial-buffer leniency window for the post-advance
2238
+ // currentItem. `fullRebuildPlayerQueue` does this before
2239
+ // inserting; surgical-trim doesn't touch items-array order, so
2240
+ // the call is explicit here.
2241
+ rearmGaplessFlip()
2242
+ let trimRange = 1 ..< delta
2243
+ let toRemove = Array(snapshot[trimRange])
2244
+ for item in toRemove {
2245
+ engine.remove(item)
2246
+ }
2247
+ engine.skipToNext()
2248
+ }
2249
+
2250
+ /// Reschedule the proactive prefetcher after a queue or skip
2251
+ /// mutation. Reads `tracks` + `currentTrackIndex` and dispatches
2252
+ /// the next N remote tracks to [LookaheadCachePrefetcher]. No-op
2253
+ /// when the prefetcher hasn't been built (configure not yet
2254
+ /// called).
2255
+ internal func rescheduleLookahead() {
2256
+ guard let prefetcher = self.lookaheadCachePrefetcher else { return }
2257
+ prefetcher.reschedule(
2258
+ tracks: self.tracks,
2259
+ currentIndex: self.currentTrackIndex
2260
+ )
2261
+ }
2262
+
2263
+ // MARK: - Playback configuration — some stubs, some lifecycle-adjacent
2264
+
2265
+ /// Mode-switch state machine — same matrix as the Android
2266
+ /// counterpart in `PlaybackModeStateMachine.kt`. Driven by
2267
+ /// `setPlaybackMode`; consumed by `getPlaybackMode` (returns
2268
+ /// `state.active`) and the boundary fire path that promotes
2269
+ /// pending modes on the next track-end.
2270
+ internal let playbackModeStateMachine = PlaybackModeStateMachine()
2271
+
2272
+ internal var playbackModeState: PlaybackModeStateMachine.State =
2273
+ PlaybackModeStateMachine.initial
2274
+
2275
+ func setPlaybackMode(mode: PlaybackMode) throws -> Promise<Void> {
2276
+ return Promise<Void>.async {
2277
+ try await self.onMainThrowing {
2278
+ let (newState, effect) = try self.playbackModeStateMachine.setRequested(
2279
+ state: self.playbackModeState,
2280
+ requested: mode
2281
+ )
2282
+ self.playbackModeState = newState
2283
+ switch effect {
2284
+ case .noOp:
2285
+ break
2286
+ case .immediateSwap:
2287
+ self.applyEngineSwapForMode(newState.active)
2288
+ }
2289
+ }
2290
+ }
2291
+ }
2292
+
2293
+ func getPlaybackMode() throws -> PlaybackMode {
2294
+ return onMainSync { self.playbackModeState.active }
2295
+ }
2296
+
2297
+ /// Build a fresh engine of the requested kind and swap it in
2298
+ /// place of the active engine. The new engine starts empty; the
2299
+ /// canonical queue model is re-installed via the engine surface
2300
+ /// so playback continues from the current track / position.
2301
+ ///
2302
+ /// JS-facing event sources differ per engine. GaplessEngine ties
2303
+ /// `self.player` to the underlying `AVQueuePlayer` and the KVO
2304
+ /// chain installed by `installGaplessObservers` /
2305
+ /// `installEventObservers` carries onTrackChange / onStateChange
2306
+ /// / onError. CrossfadeEngine sets `self.player` to nil and
2307
+ /// fires `PlaybackEngineDelegate` callbacks that this class
2308
+ /// implements (extension at the end of the file); the delegate
2309
+ /// is wired below. Both engines surface progress through
2310
+ /// `emitProgressIfSubscribed`, polymorphic over self.player vs
2311
+ /// engine state.
2312
+ internal func applyEngineSwapForMode(_ active: PlaybackMode) {
2313
+ let oldEngine = self.engine
2314
+ // Capture pre-swap state — the engine swap releases the old
2315
+ // engine; reading `newEngine.currentPositionSeconds` /
2316
+ // `isPlaying` after would always return 0 / false. The
2317
+ // wasPlaying capture is what carries playback continuity
2318
+ // across the immediate engine swap.
2319
+ let resumePosition = oldEngine?.currentPositionSeconds ?? 0
2320
+ let resumeIndex = self.currentTrackIndex
2321
+ let wasPlaying = oldEngine?.isPlaying ?? false
2322
+
2323
+ let newEngine: PlaybackEngine
2324
+ switch active.kind {
2325
+ case .gapless:
2326
+ newEngine = GaplessEngine()
2327
+ case .crossfade:
2328
+ // PlaybackModeStateMachine.requireValid guarantees this is
2329
+ // non-nil + within [1000, 12000] when `kind == .crossfade`.
2330
+ // Fail loud rather than silently degrading to 0 (which would
2331
+ // route through CrossfadeEngine's hard-cut path + skip
2332
+ // engineCrossfadeDidBegin entirely).
2333
+ guard let durationMs = active.crossfadeDurationMs else {
2334
+ preconditionFailure("PlaybackMode(crossfade) missing crossfadeDurationMs at engine construction")
2335
+ }
2336
+ let durationSeconds = durationMs / 1000.0
2337
+ newEngine = CrossfadeEngine(crossfadeDurationSeconds: durationSeconds)
2338
+ }
2339
+
2340
+ self.engine = newEngine
2341
+ // GaplessEngine path: keep the established self.player KVO
2342
+ // chain (currentItem / timeControlStatus / item.status) as
2343
+ // the canonical event source. CrossfadeEngine path:
2344
+ // self.player stays nil; the engine fires PlaybackEngineDelegate
2345
+ // callbacks for track-change / state-change / format-ready /
2346
+ // failed / play-to-end / queue-end and progress is sourced
2347
+ // from the engine via a Timer-driven fallback.
2348
+ if let gapless = newEngine as? GaplessEngine {
2349
+ self.player = gapless.player
2350
+ self.gaplessFlipArmed = true
2351
+ self.installGaplessObservers(on: gapless.player)
2352
+ self.installEventObservers(on: gapless.player)
2353
+ self.stopProgressFallbackTimer()
2354
+ // Gapless does not need the engine delegate — the underlying
2355
+ // self.player KVO chain is the canonical event source.
2356
+ newEngine.delegate = nil
2357
+ } else {
2358
+ self.tearDownGaplessObservers()
2359
+ self.tearDownEventObservers()
2360
+ self.player = nil
2361
+ newEngine.delegate = self
2362
+ self.startProgressFallbackTimer()
2363
+ // The crossfade engine re-attaches queue items at skip + fade-arm
2364
+ // without re-running AVQueueBuilder.makeItem, so it can't see a
2365
+ // track that cached after the queue was built. Hand it a cache
2366
+ // resolver (read lazily so it always sees the current cache) so
2367
+ // those late attaches swap a remote item for its local cache file.
2368
+ if let crossfade = newEngine as? CrossfadeEngine {
2369
+ crossfade.setCachedURLResolver { [weak self] url in
2370
+ guard let cached = self?.lookaheadCache?.cachedURL(forUrl: url) else {
2371
+ return nil
2372
+ }
2373
+ // Mirror AVQueueBuilder.makeItem's hit path: bump LRU so the
2374
+ // track being attached for playback isn't the next evicted.
2375
+ self?.lookaheadCache?.touch(url: url)
2376
+ return cached
2377
+ }
2378
+ }
2379
+ }
2380
+
2381
+ // Re-wire the shared audio-tap provider to the new engine's
2382
+ // audio-mix provider slot.
2383
+ AudioTapProvider.shared.wireToPlaybackEngine(newEngine)
2384
+
2385
+ // Re-apply canonical config to the freshly-built engine BEFORE the
2386
+ // queue install / play — it starts at engine defaults (repeat off,
2387
+ // volume 1, speed 1), so without this a gapless↔crossfade swap
2388
+ // silently drops the user's settings.
2389
+ newEngine.setRepeatMode(self.repeatModeState)
2390
+ newEngine.setVolume(self.volumeState)
2391
+ // Carry the user's speed into the new engine. When resuming playback,
2392
+ // apply it live via `setPlaybackSpeed` (sets `AVPlayer.rate`) BEFORE
2393
+ // `setItems` — GaplessEngine.setItems captures the player's rate to
2394
+ // restore it across the queue rebuild, so a speed applied after
2395
+ // setItems/play() would be clobbered by that async restore. When
2396
+ // paused, seed it only (no rate write, so the engine stays paused);
2397
+ // play() applies it on the next resume.
2398
+ if wasPlaying {
2399
+ newEngine.setPlaybackSpeed(self.playbackSpeedState)
2400
+ } else {
2401
+ newEngine.seedPlaybackSpeed(self.playbackSpeedState)
2402
+ }
2403
+ newEngine.setPitchCorrectionMode(self.pitchCorrectionModeState)
2404
+
2405
+ // Re-install canonical queue model.
2406
+ if resumeIndex >= 0, resumeIndex < self.tracks.count {
2407
+ let items = AVQueueBuilder.buildPlayerItems(
2408
+ tracks: self.tracks,
2409
+ queueItemIds: self.queueItemIds,
2410
+ config: self.config,
2411
+ cache: self.lookaheadCache
2412
+ )
2413
+ newEngine.setItems(
2414
+ items,
2415
+ startIndex: resumeIndex,
2416
+ startPositionSeconds: resumePosition
2417
+ )
2418
+ // Honour pre-swap playing state so a mid-play
2419
+ // setPlaybackMode keeps playing on the new engine. Without
2420
+ // this, every immediate swap silently pauses the user —
2421
+ // the new engine starts paused by construction and nothing
2422
+ // else triggers play() in this path.
2423
+ if wasPlaying {
2424
+ newEngine.play()
2425
+ }
2426
+ }
2427
+
2428
+ oldEngine?.release()
2429
+ }
2430
+
2431
+ func setPlaybackSpeed(rate: Double) throws -> Promise<Void> {
2432
+ return Promise<Void>.async {
2433
+ // Clamp to a 0.25 floor: rate=0 on AVPlayer is functionally
2434
+ // equivalent to pause(), which would surprise consumers calling
2435
+ // `setPlaybackSpeed(0)` for a slow-down effect. Cross-platform
2436
+ // contract documented in TrackPlayer.nitro.ts. Mutate on main
2437
+ // — AVPlayer property writes outside main are documented races.
2438
+ let safeRate = Float(max(0.25, rate))
2439
+ await self.onMain {
2440
+ self.playbackSpeedState = safeRate
2441
+ // Apply to the LIVE rate only when the user wants playback (already
2442
+ // playing, OR buffering toward play). On a paused/idle engine writing
2443
+ // `AVPlayer.rate` starts it (rate > 0 == "play"), so with no play intent
2444
+ // seed the speed instead (no rate write); `play()` applies it on the next
2445
+ // resume. This keeps a persisted speed applied at app start — before a
2446
+ // restored `setQueue` — from auto-starting a paused queue. `wantsToPlay`
2447
+ // (not just `isPlaying`) covers the buffering-toward-play window so a
2448
+ // speed change there still applies live.
2449
+ if self.wantsToPlay || self.engine?.isPlaying == true {
2450
+ self.engine?.setPlaybackSpeed(safeRate)
2451
+ } else {
2452
+ self.engine?.seedPlaybackSpeed(safeRate)
2453
+ }
2454
+ }
2455
+ }
2456
+ }
2457
+ func setPitchCorrectionMode(mode: PitchCorrectionMode) throws -> Promise<Void> {
2458
+ return Promise<Void>.async {
2459
+ // Mutate on main — the engine writes AVPlayerItem properties, and
2460
+ // AVPlayer/AVPlayerItem writes outside main are documented races.
2461
+ await self.onMain {
2462
+ self.pitchCorrectionModeState = mode
2463
+ self.engine?.setPitchCorrectionMode(mode)
2464
+ }
2465
+ }
2466
+ }
2467
+ func getPitchCorrectionMode() throws -> PitchCorrectionMode {
2468
+ return onMainSync { self.pitchCorrectionModeState }
2469
+ }
2470
+ func setVolume(volume: Double) throws -> Promise<Void> {
2471
+ return Promise<Void>.async {
2472
+ if CastTransportRouter.routeSetVolume(value: volume) { return }
2473
+ // Mutate on main — AVPlayer property writes outside main are
2474
+ // documented races. Matches the main-hop discipline applied
2475
+ // to setPlaybackSpeed.
2476
+ let level = Float(volume)
2477
+ await self.onMain {
2478
+ self.volumeState = level
2479
+ self.engine?.setVolume(level)
2480
+ }
2481
+ }
2482
+ }
2483
+ func setSleepTimer(seconds: Double) throws -> Promise<Void> {
2484
+ return Promise<Void>.async {
2485
+ await self.onMain { self.setSleepTimerInternal(seconds: seconds) }
2486
+ }
2487
+ }
2488
+ func setSleepTimerToTrackEnd() throws -> Promise<Void> {
2489
+ return Promise<Void>.async {
2490
+ await self.onMain { self.armSleepTimerToTrackEndInternal() }
2491
+ }
2492
+ }
2493
+ func clearSleepTimer() throws -> Promise<Void> {
2494
+ return Promise<Void>.async {
2495
+ await self.onMain { self.clearSleepTimerInternal() }
2496
+ }
2497
+ }
2498
+ func getSleepTimer() throws -> SleepTimerState {
2499
+ return onMainSync { self.sleepTimerSnapshot() }
2500
+ }
2501
+ func onSleepTimerChange(
2502
+ callback: @escaping (_ state: SleepTimerState) -> Void
2503
+ ) throws -> () -> Void {
2504
+ let id = sleepTimerListeners.add(callback)
2505
+ DispatchQueue.main.async { [weak self] in
2506
+ guard let self else { return }
2507
+ guard self.sleepTimerListeners.contains(id: id) else { return }
2508
+ callback(self.sleepTimerSnapshot())
2509
+ }
2510
+ return { [weak self] in _ = self?.sleepTimerListeners.remove(id: id) }
2511
+ }
2512
+
2513
+ // MARK: - Sleep timer — internal
2514
+
2515
+ /// Arm a wall-clock duration timer; a non-positive value clears any timer.
2516
+ private func setSleepTimerInternal(seconds: Double) {
2517
+ guard seconds.isFinite, seconds > 0 else {
2518
+ clearSleepTimerInternal()
2519
+ return
2520
+ }
2521
+ restoreSleepTimerVolume()
2522
+ sleepTimerCore.armDuration(seconds: seconds, nowMs: Self.nowEpochMs())
2523
+ startSleepTimerTick()
2524
+ emitSleepTimerChanged()
2525
+ }
2526
+
2527
+ private func armSleepTimerToTrackEndInternal() {
2528
+ restoreSleepTimerVolume()
2529
+ sleepTimerCore.armEndOfTrack()
2530
+ startSleepTimerTick()
2531
+ emitSleepTimerChanged()
2532
+ }
2533
+
2534
+ private func clearSleepTimerInternal() {
2535
+ let wasActive = sleepTimerCore.isActive
2536
+ restoreSleepTimerVolume()
2537
+ sleepTimerCore.clear()
2538
+ stopSleepTimerTick()
2539
+ if wasActive { emitSleepTimerChanged() }
2540
+ }
2541
+
2542
+ private func sleepTimerSnapshot() -> SleepTimerState {
2543
+ let now = Self.nowEpochMs()
2544
+ return SleepTimerState(
2545
+ active: sleepTimerCore.isActive,
2546
+ endsAtEpochMs: sleepTimerCore.deadlineEpochMs.map { Double($0) },
2547
+ remainingSeconds: sleepTimerCore.remainingSeconds(nowMs: now).map { Double($0) },
2548
+ endOfTrack: sleepTimerCore.isEndOfTrack)
2549
+ }
2550
+
2551
+ private func emitSleepTimerChanged() {
2552
+ let snapshot = sleepTimerSnapshot()
2553
+ sleepTimerListeners.forEach { $0(snapshot) }
2554
+ }
2555
+
2556
+ private func startSleepTimerTick() {
2557
+ guard sleepTimerTimer == nil else { return }
2558
+ let timer = Timer.scheduledTimer(
2559
+ withTimeInterval: Self.sleepTimerTickInterval,
2560
+ repeats: true
2561
+ ) { [weak self] _ in
2562
+ self?.onSleepTimerTick()
2563
+ }
2564
+ sleepTimerTimer = timer
2565
+ }
2566
+
2567
+ private func stopSleepTimerTick() {
2568
+ sleepTimerTimer?.invalidate()
2569
+ sleepTimerTimer = nil
2570
+ }
2571
+
2572
+ private func onSleepTimerTick() {
2573
+ // While casting, the local engine is intentionally frozen (its position
2574
+ // never advances), so feed the state machine the receiver's position /
2575
+ // duration instead — otherwise the end-of-track tail rule never trips and
2576
+ // `.endOfTrack` stays idle forever.
2577
+ let durationSec: Double
2578
+ let positionSec: Double
2579
+ if let remote = PlaybackStateRouter.shared.activeRemote() {
2580
+ let d = Double(remote.lastDurationMs) / 1000.0
2581
+ let p = Double(remote.lastPositionMs) / 1000.0
2582
+ durationSec = (d.isFinite && d > 0) ? d : 0
2583
+ positionSec = (p.isFinite && p > 0) ? p : 0
2584
+ } else {
2585
+ let duration = self.engine?.currentDurationSeconds ?? 0
2586
+ let position = self.engine?.currentPositionSeconds ?? 0
2587
+ durationSec = (duration.isFinite && duration > 0) ? duration : 0
2588
+ positionSec = (position.isFinite && position > 0) ? position : 0
2589
+ }
2590
+ let result = sleepTimerCore.tick(
2591
+ nowMs: Self.nowEpochMs(),
2592
+ trackDurationSec: durationSec,
2593
+ trackPositionSec: positionSec)
2594
+ applySleepTimerFade(result.fadeFraction)
2595
+ if result.stateChanged { emitSleepTimerChanged() }
2596
+ if result.pauseNow {
2597
+ // Route the boundary pause to the receiver when casting (the receiver
2598
+ // emits its own paused state); only the local-engine pause needs the
2599
+ // sleep-timer reason tag.
2600
+ if !CastTransportRouter.routePause() {
2601
+ self.pendingStateChangeReason = .sleepTimer
2602
+ self.engine?.pause()
2603
+ }
2604
+ }
2605
+ if !sleepTimerCore.isActive { stopSleepTimerTick() }
2606
+ }
2607
+
2608
+ /// Fade multiplies off `volumeState` without mutating it, so `getVolume`
2609
+ /// stays truthful and the pre-fade level is restored on clear/fire.
2610
+ private func applySleepTimerFade(_ fraction: Double) {
2611
+ // Fade is local-only: the receiver's volume is user/system-owned and
2612
+ // `volumeState` tracks the local level, so ramping it on the receiver would
2613
+ // clobber the cast volume. When casting the boundary pause still routes to
2614
+ // the receiver; the pre-pause fade is skipped. Undo any in-progress local
2615
+ // fade first so a cast session appearing mid-fade can't strand the local
2616
+ // engine at a ramped-down volume for a later cast→local return.
2617
+ if PlaybackStateRouter.shared.activeRemote() != nil {
2618
+ restoreSleepTimerVolume()
2619
+ return
2620
+ }
2621
+ if fraction < 1.0 {
2622
+ self.engine?.setVolume(Float(Double(self.volumeState) * fraction))
2623
+ sleepTimerFading = true
2624
+ } else if sleepTimerFading {
2625
+ self.engine?.setVolume(self.volumeState)
2626
+ sleepTimerFading = false
2627
+ }
2628
+ }
2629
+
2630
+ private func restoreSleepTimerVolume() {
2631
+ if sleepTimerFading {
2632
+ self.engine?.setVolume(self.volumeState)
2633
+ sleepTimerFading = false
2634
+ }
2635
+ }
2636
+
2637
+ private static func nowEpochMs() -> Int64 {
2638
+ Int64(Date().timeIntervalSince1970 * 1000)
2639
+ }
2640
+ func setRepeatMode(mode: RepeatMode) throws -> Promise<Void> {
2641
+ return Promise<Void>.async {
2642
+ await self.onMain {
2643
+ self.repeatModeState = mode
2644
+ // Forward to the active engine. The CrossfadeEngine needs the
2645
+ // mode to suppress its look-ahead fade + loop the current item
2646
+ // under `.track` (AVPlayer has no native repeat); GaplessEngine
2647
+ // treats it as a no-op (repeat-track replay lives in
2648
+ // handlePlayerItemDidPlayToEndTime).
2649
+ self.engine?.setRepeatMode(mode)
2650
+ // Both canSkipNext and canSkipPrevious depend on repeat mode
2651
+ // (e.g. at idx 0 / last index, OFF↔QUEUE flips the boolean
2652
+ // because QUEUE wraps). Recompute is cheap; dedup keeps the
2653
+ // listener silent when the (prev, next) tuple is unchanged.
2654
+ self.recomputeCapabilities()
2655
+ }
2656
+ }
2657
+ }
2658
+
2659
+ func setRemoteControls(options: RemoteControlOptions) throws -> Promise<Void> {
2660
+ return Promise<Void>.async {
2661
+ // Guard nonsensical negatives (a negative forward interval would seek
2662
+ // backward). Non-positive is clamped to 0 rather than rejected.
2663
+ let fwd = max(0, options.forwardJumpInterval)
2664
+ let back = max(0, options.backwardJumpInterval)
2665
+ await self.onMain {
2666
+ self.remoteSkipMode = options.skipMode
2667
+ self.remoteForwardInterval = fwd
2668
+ self.remoteBackwardInterval = back
2669
+ self.remoteCommands.setRemoteControls(
2670
+ mode: options.skipMode,
2671
+ forwardInterval: fwd,
2672
+ backwardInterval: back)
2673
+ // Re-apply the cached grey-out: switching to track mode re-enables
2674
+ // next/prev, which must reflect the queue-boundary capability
2675
+ // (setSkipCapability is otherwise only called on queue/repeat change,
2676
+ // and no-ops in interval mode).
2677
+ self.remoteCommands.setSkipCapability(
2678
+ canSkipNext: self.cachedSkipCapability.canSkipNext,
2679
+ canSkipPrevious: self.cachedSkipCapability.canSkipPrevious)
2680
+ }
2681
+ }
2682
+ }
2683
+
2684
+ // ReplayGain mode — thin wrapper around `AudioTapProvider.shared`.
2685
+ // The provider owns the per-item state set + recompute walk; this
2686
+ // Hybrid surface is the JS-facing setter / getter only.
2687
+ func setReplayGainMode(mode: ReplayGainMode) throws -> Promise<Void> {
2688
+ return Promise<Void>.async {
2689
+ AudioTapProvider.shared.setReplayGainMode(mode)
2690
+ // Re-emit the active item's NowPlayingFormat so
2691
+ // `onNowPlayingFormatChange` subscribers (e.g. the
2692
+ // `useNowPlayingFormat` hook) observe the new `replayGainActive`
2693
+ // flag without waiting for the next track change. Mirrors the
2694
+ // Android path's `refreshNowPlayingFormatForActiveItem` call from
2695
+ // its own `setReplayGainMode`.
2696
+ DispatchQueue.main.async {
2697
+ guard let item = self.activeMediaItem else { return }
2698
+ self.emitCurrentNowPlayingFormat(for: item)
2699
+ }
2700
+ }
2701
+ }
2702
+ func getReplayGainMode() throws -> ReplayGainMode {
2703
+ return AudioTapProvider.shared.getReplayGainMode()
2704
+ }
2705
+ /// Destructive shuffle. Mutates the current queue in-place via
2706
+ /// Fisher-Yates, resets `currentTrackIndex` to 0, and resumes
2707
+ /// playback on the new tracks[0] if the player was playing
2708
+ /// pre-shuffle. Returns the post-shuffle snapshot so the consumer
2709
+ /// app can immediately reflect the new ordering in its UI.
2710
+ ///
2711
+ /// The pre-shuffle ordering is not preserved; once shuffled the
2712
+ /// queue stays in the new order until the consumer calls
2713
+ /// `setQueue` to install a fresh ordering.
2714
+ ///
2715
+ /// Empty queue → returns `{ tracks: [], currentIndex: -1, currentTrack: nil }`
2716
+ /// without mutating state.
2717
+ func shuffleQueue() throws -> Promise<ShuffleResult> {
2718
+ return Promise<ShuffleResult>.async {
2719
+ // `self.onMain` is Void-returning; we capture the result in a
2720
+ // local var inside the body and return it from the outer
2721
+ // Promise closure after the main-thread work completes.
2722
+ var result = ShuffleResult(
2723
+ tracks: [], currentIndex: -1, currentTrack: nil)
2724
+ await self.onMain {
2725
+ // Capture playing state BEFORE the mutation so we know whether
2726
+ // to resume after the rebuild. Read via the engine surface so
2727
+ // crossfade mode (where `self.player` is nil) is reflected.
2728
+ let wasPlaying = self.engine?.isPlaying ?? false
2729
+
2730
+ if self.tracks.isEmpty {
2731
+ // Empty queue — leave state untouched, return empty
2732
+ // snapshot. result already initialised to that.
2733
+ return
2734
+ }
2735
+ guard self.engine != nil else {
2736
+ // No engine attached (configure() not yet called). Bail with
2737
+ // empty result rather than silently mutating tracks but not
2738
+ // the player — the prior shape returned a "shuffled"
2739
+ // snapshot while leaving self.tracks unchanged, which lied
2740
+ // to the consumer. Gate on `self.engine` (canonical lib-
2741
+ // initialised sentinel); `self.player` is gapless-only and
2742
+ // nil under CrossfadeEngine.
2743
+ return
2744
+ }
2745
+
2746
+ // Fisher-Yates via Swift stdlib (Apple-documented contract).
2747
+ var newTracks = self.tracks
2748
+ newTracks.shuffle()
2749
+
2750
+ self.performingMutation {
2751
+ // Mirrors setQueue — `replaceAll` regenerates queueItemIds
2752
+ // so each new AVPlayerItem gets a unique lib-internal
2753
+ // identity and the destructive shuffle re-keys every
2754
+ // position.
2755
+ let newIds = self.queueState.replaceAll(newTracks)
2756
+ self.currentTrackIndex = 0
2757
+ self.pendingTrackChangeReason = .queueReplaced
2758
+ let attempts = self.effectiveAutoRetries()
2759
+ self.retryAttemptsRemaining = Dictionary(
2760
+ uniqueKeysWithValues: newIds.map { ($0, attempts) }
2761
+ )
2762
+ self.fullRebuildPlayerQueue()
2763
+ }
2764
+ self.rescheduleLookahead()
2765
+ self.recomputeCapabilities()
2766
+
2767
+ // Resume playback if the user was playing before the shuffle.
2768
+ // Stays paused otherwise — consumer can call play() if they
2769
+ // want to start from the new track 0.
2770
+ if wasPlaying {
2771
+ self.pendingStateChangeReason = .user
2772
+ self.engine?.play()
2773
+ }
2774
+
2775
+ result = ShuffleResult(
2776
+ tracks: self.tracks,
2777
+ currentIndex: 0,
2778
+ currentTrack: self.tracks[0])
2779
+ }
2780
+ return result
2781
+ }
2782
+ }
2783
+
2784
+ // MARK: - State snapshots
2785
+ //
2786
+ // Nitro dispatches JS calls on a worker thread, NOT main. Apple's
2787
+ // AVFoundation guidance ("serialize your access to Player and
2788
+ // PlayerItem on the main queue") means every read of AVPlayer
2789
+ // state must hop. The cached `canSkip*` Bools are written from
2790
+ // main inside `recomputeCapabilities()`, so they ride the same
2791
+ // hop for consistency. `onMainSync` short-circuits when already
2792
+ // on main, so callers that are on main don't deadlock.
2793
+ func getState() throws -> PlayerState {
2794
+ return onMainSync {
2795
+ // Both engines derive PlayerState from the same inputs via
2796
+ // `engine.timeControlStatus` (gapless returns its AVQueuePlayer's, which
2797
+ // is what `self.player` also points at; crossfade returns the active
2798
+ // leg's), so gapless + crossfade report identical states. `.none` when no
2799
+ // engine is configured.
2800
+ guard let engine = self.engine else { return .none }
2801
+ return PlayerStateDerivation.translate(
2802
+ status: engine.timeControlStatus,
2803
+ currentIndex: self.currentTrackIndex,
2804
+ hasCurrentItem: engine.currentMediaItem != nil,
2805
+ reachedQueueEnd: self.reachedQueueEnd
2806
+ )
2807
+ }
2808
+ }
2809
+ func getCurrentTrackIndex() throws -> Double {
2810
+ // Nitrogen spec types this return as Double; bridge the internal Int.
2811
+ return onMainSync { Double(self.currentTrackIndex) }
2812
+ }
2813
+ func getCurrentTrackSource() throws -> TrackSource? {
2814
+ return onMainSync { self.currentTrackSource }
2815
+ }
2816
+ func getCanSkipNext() throws -> Bool {
2817
+ return onMainSync { self.cachedSkipCapability.canSkipNext }
2818
+ }
2819
+ func getSkipCapability() throws -> SkipCapability {
2820
+ return onMainSync { self.cachedSkipCapability }
2821
+ }
2822
+ func getCanSkipPrevious() throws -> Bool {
2823
+ return onMainSync { self.cachedSkipCapability.canSkipPrevious }
2824
+ }
2825
+ func getPlaybackSpeed() throws -> Double {
2826
+ return onMainSync { Double(self.playbackSpeedState) }
2827
+ }
2828
+ func getVolume() throws -> Double {
2829
+ return onMainSync { Double(self.volumeState) }
2830
+ }
2831
+ func getRemoteControls() throws -> RemoteControlOptions {
2832
+ return onMainSync {
2833
+ let options: RemoteControlOptions = .init(
2834
+ skipMode: self.remoteSkipMode,
2835
+ forwardJumpInterval: self.remoteForwardInterval,
2836
+ backwardJumpInterval: self.remoteBackwardInterval)
2837
+ return options
2838
+ }
2839
+ }
2840
+
2841
+ func getBufferState() throws -> BufferState {
2842
+ return onMainSync { self.currentBufferState }
2843
+ }
2844
+
2845
+ func getIsSeekable() throws -> Bool {
2846
+ return onMainSync {
2847
+ guard let item = self.engine?.currentMediaItem else { return true }
2848
+ return !item.seekableTimeRanges.isEmpty
2849
+ }
2850
+ }
2851
+
2852
+ func isFullyBuffered() throws -> Bool {
2853
+ return onMainSync { self.currentFullyBuffered }
2854
+ }
2855
+
2856
+ func onBufferStateChange(
2857
+ callback: @escaping (_ state: BufferState) -> Void
2858
+ ) throws -> () -> Void {
2859
+ let id = bufferStateListeners.add(callback)
2860
+ // Fire once on subscribe with the current state so a UI renders immediately.
2861
+ DispatchQueue.main.async { [weak self] in
2862
+ guard let self else { return }
2863
+ guard self.bufferStateListeners.contains(id: id) else { return }
2864
+ callback(self.currentBufferState)
2865
+ }
2866
+ return { [weak self] in self?.bufferStateListeners.remove(id: id) }
2867
+ }
2868
+
2869
+ func onFullyBufferedChange(
2870
+ callback: @escaping (_ fullyBuffered: Bool) -> Void
2871
+ ) throws -> () -> Void {
2872
+ let id = fullyBufferedListeners.add(callback)
2873
+ DispatchQueue.main.async { [weak self] in
2874
+ guard let self else { return }
2875
+ guard self.fullyBufferedListeners.contains(id: id) else { return }
2876
+ callback(self.currentFullyBuffered)
2877
+ }
2878
+ return { [weak self] in self?.fullyBufferedListeners.remove(id: id) }
2879
+ }
2880
+
2881
+ // MARK: - Events — public registration surface
2882
+ //
2883
+ // Each event has a multi-listener `ListenerRegistry`. Each `onXxx`
2884
+ // call appends a new entry; the returned closure removes that entry
2885
+ // by stable id. ListenerRegistry's internal queue.sync makes
2886
+ // add/remove safe against concurrent fire rounds — fire iterates a
2887
+ // snapshot taken under the lock.
2888
+
2889
+ func onStateChange(
2890
+ callback: @escaping (_ state: PlayerState, _ reason: StateChangeReason) -> Void
2891
+ ) throws -> () -> Void {
2892
+ let id = stateChangeListeners.add(callback)
2893
+ return { [weak self] in self?.stateChangeListeners.remove(id: id) }
2894
+ }
2895
+
2896
+ func onTrackChange(
2897
+ callback: @escaping (_ track: TrackItem?, _ index: Double, _ reason: TrackChangeReason, _ transitionGapMs: Double?) -> Void
2898
+ ) throws -> () -> Void {
2899
+ let id = trackChangeListeners.add(callback)
2900
+ return { [weak self] in self?.trackChangeListeners.remove(id: id) }
2901
+ }
2902
+
2903
+ func onProgress(
2904
+ callback: @escaping (_ progress: PlayerProgress) -> Void
2905
+ ) throws -> () -> Void {
2906
+ let id = progressListeners.add(callback)
2907
+ return { [weak self] in self?.progressListeners.remove(id: id) }
2908
+ }
2909
+
2910
+ func onPlaybackMilestone(
2911
+ callback: @escaping (_ milestone: Double, _ trackIndex: Double) -> Void
2912
+ ) throws -> () -> Void {
2913
+ let id = milestoneListeners.add(callback)
2914
+ return { [weak self] in self?.milestoneListeners.remove(id: id) }
2915
+ }
2916
+
2917
+ func onError(callback: @escaping (_ error: PlaybackError) -> Void) throws -> () -> Void {
2918
+ let id = errorListeners.add(callback)
2919
+ return { [weak self] in self?.errorListeners.remove(id: id) }
2920
+ }
2921
+
2922
+ func onQueueEnd(callback: @escaping () -> Void) throws -> () -> Void {
2923
+ let id = queueEndListeners.add(callback)
2924
+ return { [weak self] in self?.queueEndListeners.remove(id: id) }
2925
+ }
2926
+
2927
+ /// Subscribe to skip-capability changes. Auto-fires the callback
2928
+ /// once on registration with the current cached values (mirrors
2929
+ /// Media3 `Player.Listener.onAvailableCommandsChanged`) so new
2930
+ /// subscribers don't have to seed via the getters first. Hop to
2931
+ /// main for the auto-fire so the cache read is on the same
2932
+ /// queue that mutates it. Subsequent fires happen synchronously
2933
+ /// from `recomputeCapabilities()` (which is itself main-bound).
2934
+ ///
2935
+ /// **Dispose race guard:** the deferred main fire checks the
2936
+ /// subscriber is still registered (`contains(id:)`) before
2937
+ /// invoking the callback. Strict-mode subscribe → dispose →
2938
+ /// subscribe sequences fire the cleanup before the deferred fire
2939
+ /// runs; without the guard, the auto-fire would call into an
2940
+ /// already-unmounted React component's `setSnapshot`.
2941
+ func onSkipCapabilityChange(
2942
+ callback: @escaping (_ capability: SkipCapability) -> Void
2943
+ ) throws -> () -> Void {
2944
+ let id = skipCapabilityListeners.add(callback)
2945
+ DispatchQueue.main.async { [weak self] in
2946
+ guard let self else { return }
2947
+ guard self.skipCapabilityListeners.contains(id: id) else { return }
2948
+ callback(SkipCapability(
2949
+ canSkipPrevious: self.cachedSkipCapability.canSkipPrevious,
2950
+ canSkipNext: self.cachedSkipCapability.canSkipNext))
2951
+ }
2952
+ return { [weak self] in self?.skipCapabilityListeners.remove(id: id) }
2953
+ }
2954
+
2955
+ // MARK: - Events — observer wiring
2956
+
2957
+ /// Install the periodic progress observer, state-change KVO, and
2958
+ /// the NotificationCenter observers for error + end-of-item.
2959
+ /// Track-change is piggybacked on the existing `\.currentItem`
2960
+ /// KVO installed by `installGaplessObservers` — see
2961
+ /// `handleCurrentItemDidChange`.
2962
+ private func installEventObservers(on player: AVQueuePlayer) {
2963
+ tearDownEventObservers()
2964
+
2965
+ // 2 Hz progress tick — per NOTES.md §5, native periodic timer
2966
+ // (not JS setInterval) is load-bearing for backgrounded playback.
2967
+ let interval = CMTime(
2968
+ seconds: Self.progressIntervalSeconds,
2969
+ preferredTimescale: CMTimeScale(NSEC_PER_SEC))
2970
+ let token = player.addPeriodicTimeObserver(
2971
+ forInterval: interval, queue: .main
2972
+ ) { [weak self] _ in
2973
+ self?.emitProgressIfSubscribed()
2974
+ self?.processMilestones()
2975
+ self?.recomputeBufferState()
2976
+ self?.recomputeFullyBuffered()
2977
+ }
2978
+ progressTimeObserver = (player: player, token: token)
2979
+
2980
+ // `.new`-only KVO + async-to-main bootstrap. See
2981
+ // `installGaplessObservers` for the rationale on why `.initial`
2982
+ // is avoided lib-wide.
2983
+ timeControlStatusObserver = player.observe(
2984
+ \.timeControlStatus, options: [.new]
2985
+ ) { [weak self] player, _ in
2986
+ #if DEBUG
2987
+ let observedStatus = player.timeControlStatus
2988
+ #endif
2989
+ DispatchQueue.main.async {
2990
+ guard let self else { return }
2991
+ #if DEBUG
2992
+ if observedStatus == .playing && self.gaplessLogLastChangedAtNs > 0 {
2993
+ let now = DispatchTime.now().uptimeNanoseconds
2994
+ let deltaMs = Double(now &- self.gaplessLogLastChangedAtNs) / 1_000_000.0
2995
+ NSLog("[RNQP-GAPLESS] tcs-playing trackIdx=%d ts_ns=%llu delta_since_changed_ms=%.3f",
2996
+ self.currentTrackIndex, now, deltaMs)
2997
+ // One-shot: clear the marker so a subsequent buffering/play
2998
+ // cycle within the same track doesn't re-log against the
2999
+ // stale baseline.
3000
+ self.gaplessLogLastChangedAtNs = 0
3001
+ }
3002
+ #endif
3003
+ self.emitStateChangeIfChanged()
3004
+ }
3005
+ }
3006
+ // Bootstrap to replicate what `.initial` would have delivered.
3007
+ // Contract: this consumes any staked-but-not-yet-emitted
3008
+ // `pendingStateChangeReason` (path: emitStateChangeIfChanged
3009
+ // consumes for non-buffering states). Today only `configure()`
3010
+ // (gated on `self.player == nil`) reaches here, and at first
3011
+ // configure `pendingStateChangeReason` is nil — so the
3012
+ // consumption is a no-op. A future caller that invokes
3013
+ // installEventObservers from a path where a reason is staked
3014
+ // would silently swallow that reason; if such a path is ever
3015
+ // added, restore the contract by stashing/reinstating the
3016
+ // reason around this bootstrap.
3017
+ DispatchQueue.main.async { [weak self] in
3018
+ self?.emitStateChangeIfChanged()
3019
+ }
3020
+
3021
+ NotificationCenter.default.addObserver(
3022
+ self, selector: #selector(handlePlayerItemFailedToPlayToEndTime(_:)),
3023
+ name: .AVPlayerItemFailedToPlayToEndTime, object: nil)
3024
+ // Explicit AVPlayerItemDidPlayToEndTime observer. The SOLE
3025
+ // authoritative signal for natural end-of-track,
3026
+ // distinguishing real auto-advance from chain-advance bounces
3027
+ // (AVQueuePlayer walking past unbuffered items during a rebuild
3028
+ // does not fire DidPlayToEndTime). The handler sets
3029
+ // `didPlayToEndPending` for `handleCurrentItemDidChange` to gate
3030
+ // the matchTrackIndex resync, AND handles the repeat-track mode
3031
+ // auto-rewind (seek to zero + replay). Item-status observation
3032
+ // (initial-buffer leniency + load-failure → onError) is
3033
+ // installed by `attachStatusObserverIfArmed`, which runs on
3034
+ // every currentItem swap from the gapless KVO.
3035
+ NotificationCenter.default.addObserver(
3036
+ self, selector: #selector(handlePlayerItemDidPlayToEndTime(_:)),
3037
+ name: .AVPlayerItemDidPlayToEndTime, object: nil)
3038
+ // Access-log entries arrive multiple times per second during
3039
+ // streaming (per network transfer chunk). Two consumers share the
3040
+ // notification: (1) the streaming-MP3 bitrate fallback chain
3041
+ // `handleNewAccessLogEntry → reresolveNowPlayingFormatForBitrate →
3042
+ // NowPlayingFormatExtractor.extract → readStreamingMP3Bitrate`
3043
+ // re-resolves the now-playing format for items whose cached
3044
+ // bitrate is still null (CBR stream where `estimatedDataRate` came
3045
+ // back zero); (2) the DEBUG gapless logger records stalls /
3046
+ // observed bitrate / transfer duration against the transition
3047
+ // timeline. The error-log subscription is DEBUG-only — it has no
3048
+ // production consumer.
3049
+ NotificationCenter.default.addObserver(
3050
+ self, selector: #selector(handleNewAccessLogEntry(_:)),
3051
+ name: AVPlayerItem.newAccessLogEntryNotification, object: nil)
3052
+ #if DEBUG
3053
+ NotificationCenter.default.addObserver(
3054
+ self, selector: #selector(handleNewErrorLogEntry(_:)),
3055
+ name: AVPlayerItem.newErrorLogEntryNotification, object: nil)
3056
+ #endif
3057
+ }
3058
+
3059
+ private func tearDownEventObservers() {
3060
+ if let pair = progressTimeObserver {
3061
+ pair.player.removeTimeObserver(pair.token)
3062
+ progressTimeObserver = nil
3063
+ }
3064
+ timeControlStatusObserver?.invalidate()
3065
+ timeControlStatusObserver = nil
3066
+ NotificationCenter.default.removeObserver(
3067
+ self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil)
3068
+ NotificationCenter.default.removeObserver(
3069
+ self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
3070
+ NotificationCenter.default.removeObserver(
3071
+ self, name: AVPlayerItem.newAccessLogEntryNotification, object: nil)
3072
+ #if DEBUG
3073
+ NotificationCenter.default.removeObserver(
3074
+ self, name: AVPlayerItem.newErrorLogEntryNotification, object: nil)
3075
+ #endif
3076
+ }
3077
+
3078
+ /// Start polling the engine for progress at the same 0.5s
3079
+ /// cadence the AVPlayer periodic time observer would. Used when
3080
+ /// `self.player` is nil (CrossfadeEngine active). Idempotent —
3081
+ /// repeat calls drop the prior timer first.
3082
+ private func startProgressFallbackTimer() {
3083
+ stopProgressFallbackTimer()
3084
+ self.lastEmittedCrossfadePosition = -1
3085
+ let timer = Timer.scheduledTimer(
3086
+ withTimeInterval: Self.progressIntervalSeconds,
3087
+ repeats: true
3088
+ ) { [weak self] _ in
3089
+ // Timer fires on the run loop the timer was scheduled on
3090
+ // (main, since this is invoked from `applyEngineSwapForMode`
3091
+ // which is on main). Run the same `emitProgressIfSubscribed`
3092
+ // body the periodic observer would.
3093
+ self?.emitProgressIfSubscribed()
3094
+ self?.processMilestones()
3095
+ self?.recomputeBufferState()
3096
+ self?.recomputeFullyBuffered()
3097
+ }
3098
+ progressFallbackTimer = timer
3099
+ }
3100
+
3101
+ private func stopProgressFallbackTimer() {
3102
+ progressFallbackTimer?.invalidate()
3103
+ progressFallbackTimer = nil
3104
+ }
3105
+
3106
+ // MARK: - Events — dispatch
3107
+
3108
+ /// Called from the `\.currentItem` KVO dispatch in
3109
+ /// `installGaplessObservers`. Fires `onTrackChange` whenever the
3110
+ /// player's current item changes, and — when the change is
3111
+ /// "currentItem went nil while the queue still had tracks" outside
3112
+ /// of an in-flight mutation — also fires `onQueueEnd` and emits an
3113
+ /// `.ended` state change.
3114
+ ///
3115
+ /// Mid-mutation nil fires are suppressed entirely. Every
3116
+ /// `removeAllItems` + `insert` pair produces a transient
3117
+ /// `currentItem == nil` observation before the real new item
3118
+ /// lands; without the `isMutatingPlayerQueue` guard we'd both
3119
+ /// consume `pendingTrackChangeReason` against the intermediate
3120
+ /// fire AND spuriously emit `onQueueEnd` on every backward skip,
3121
+ /// setQueue, full rebuild, etc.
3122
+ private func handleCurrentItemDidChange() {
3123
+ if self.isMutatingPlayerQueue && self.player?.currentItem == nil {
3124
+ return
3125
+ }
3126
+ // Snapshot timestamp first thing so `transitionGapMs` measures the
3127
+ // iOS-side notification→KVO scheduling delta, not the synchronous
3128
+ // lib work that follows. Always overwritten — cheap UInt64 store.
3129
+ self.gaplessGapMeasuredAtNs = DispatchTime.now().uptimeNanoseconds
3130
+ let reason = consumePendingReasonAndResyncIndex()
3131
+ dispatchTrackChange(reason: reason)
3132
+ // Native auto-advance can change `currentTrackIndex` (via the
3133
+ // matchTrackIndex resync) without going through any explicit
3134
+ // mutation method. Recompute capabilities here so the boundary-
3135
+ // flip canSkipNext under `.off` (last-track auto-advance leaving
3136
+ // cur on the last track with no next) lands.
3137
+ self.recomputeCapabilities()
3138
+ detectQueueEnd()
3139
+ // The matchTrackIndex resync makes `currentTrackIndex` canonical
3140
+ // post-auto-advance, so rescheduling the prefetcher window from
3141
+ // here shifts it to cur+1..cur+lookaheadCount against the
3142
+ // freshly-synced index. The prefetcher dedupes by url, so
3143
+ // re-issuing the same window mid-prefetch is cheap.
3144
+ self.rescheduleLookahead()
3145
+ }
3146
+
3147
+ /// Consume `pendingTrackChangeReason` + `didPlayToEndPending` and,
3148
+ /// when the player has auto-advanced naturally, resync
3149
+ /// `currentTrackIndex` from the player's current AVPlayerItem via
3150
+ /// `matchTrackIndex`. Returns the consumed reason for the caller
3151
+ /// to thread into `dispatchTrackChange`. Defaults to `.autoAdvance`
3152
+ /// when no user mutation stashed a reason.
3153
+ ///
3154
+ /// Resync requires BOTH `didPlayToEndPending` set AND no pending
3155
+ /// user-mutation reason. User mutations pre-write
3156
+ /// `currentTrackIndex` before the rebuild — that intent is
3157
+ /// authoritative. The reason gate covers the FB9221518 case where
3158
+ /// a paused-state `removeAllItems` fires DidPlayToEndTime for a
3159
+ /// queue item that was never played; without it the post-rebuild
3160
+ /// KVO main hop would resync to AVQueuePlayer's stale
3161
+ /// `currentItem` value (the pre-rebuild item).
3162
+ private func consumePendingReasonAndResyncIndex() -> TrackChangeReason {
3163
+ // Resync gate: only when the player auto-advanced naturally
3164
+ // (no pending user-mutation reason AND DidPlayToEndTime fired).
3165
+ //
3166
+ // Two gates AND-ed together because each catches a distinct
3167
+ // wrong-resync vector:
3168
+ //
3169
+ // 1. `didPlayToEndPending`: chain-advance bounces during a
3170
+ // `removeAllItems` + insert rebuild do NOT fire the notification,
3171
+ // so this gate excludes those.
3172
+ // 2. `pendingUserMutation`: a paused-state queue mutation
3173
+ // (skipToIndex, skipPrev, skipNext via fullRebuild) under FB9221518
3174
+ // can fire `AVPlayerItemDidPlayToEndTime` for an item being
3175
+ // pulled from the queue. Without this gate the fired
3176
+ // notification lands at the post-mutation `\.currentItem` KVO
3177
+ // main hop, sees `didPlayToEndPending == true`, and resyncs
3178
+ // `currentTrackIndex` to whatever AVQueuePlayer's
3179
+ // not-yet-published `currentItem` resolves to — which can be
3180
+ // the pre-mutation item's queueItemId. The user-mutation pre-
3181
+ // write of `currentTrackIndex` is authoritative; the resync
3182
+ // must defer to it.
3183
+ let pendingUserMutation = self.pendingTrackChangeReason != nil
3184
+ let reason = self.pendingTrackChangeReason ?? .autoAdvance
3185
+ self.pendingTrackChangeReason = nil
3186
+ let endFired = self.didPlayToEndPending
3187
+ self.didPlayToEndPending = false
3188
+ if endFired && !pendingUserMutation {
3189
+ if let derived = matchTrackIndex(forCurrentItem: self.player?.currentItem) {
3190
+ self.currentTrackIndex = derived
3191
+ }
3192
+ }
3193
+ return reason
3194
+ }
3195
+
3196
+ /// Fire `trackChangeListeners` for the current `(track, idx,
3197
+ /// reason)`, deduped on `lastEmittedQueueItemId` (NOT idx — index
3198
+ /// alone would suppress setQueue replacements that keep cur=0 with
3199
+ /// a different first track). On a real fire, also resets the
3200
+ /// error-dedup state so a new item's identical-coded error fires.
3201
+ private func dispatchTrackChange(reason: TrackChangeReason) {
3202
+ let idx = self.currentTrackIndex
3203
+ let track: TrackItem? = (idx >= 0 && idx < self.tracks.count)
3204
+ ? self.tracks[idx] : nil
3205
+ let currentItemId: String? = self.queueItemIds[safe: idx]
3206
+ let cache = self.lookaheadCache
3207
+ self.currentTrackSource = TrackPlayer.classifyTrackSource(url: track?.url) { url in
3208
+ cache?.isFullyCached(url: url) ?? false
3209
+ }
3210
+ if self.lastEmittedQueueItemId != currentItemId {
3211
+ self.lastEmittedQueueItemId = currentItemId
3212
+ self.lastErrorQueueItemId = nil
3213
+ self.lastErrorCode = nil
3214
+ // A real track (re)loaded => no longer at the end of the queue. At true
3215
+ // end-of-queue this block is skipped entirely — the last-track id still
3216
+ // matches `lastEmittedQueueItemId` — so the latch survives; the `idx >= 0`
3217
+ // guard only keeps an explicit clear to index -1 from touching it.
3218
+ if idx >= 0 { self.reachedQueueEnd = false }
3219
+ // New active track => new milestone playthrough. This is the single
3220
+ // deduped track-change point (the crossfade fade-start echo collapses
3221
+ // here), so it resets exactly once per real track change.
3222
+ self.milestoneTracker.reset()
3223
+ // New track => its initial load counts as `buffering` (not `stalled`)
3224
+ // until it reaches a playing state, and it starts not-fully-buffered.
3225
+ self.hasStartedPlaying = false
3226
+ self.recomputeFullyBuffered()
3227
+ // Measure the gapless auto-advance silence gap (nil for every
3228
+ // other transition) before the emit so it rides on onTrackChange
3229
+ // as `transitionGapMs`. The timestamp was captured at the top of
3230
+ // handleCurrentItemDidChange, above this method's synchronous lib
3231
+ // work; falls back to now() if the snapshot wasn't taken.
3232
+ var transitionGapMs: Double? = nil
3233
+ if reason == .autoAdvance && self.gaplessGapPrevEndedAtNs > 0 {
3234
+ let now = self.gaplessGapMeasuredAtNs > 0
3235
+ ? self.gaplessGapMeasuredAtNs
3236
+ : DispatchTime.now().uptimeNanoseconds
3237
+ let gapNs = now &- self.gaplessGapPrevEndedAtNs
3238
+ transitionGapMs = Double(gapNs) / 1_000_000.0
3239
+ self.gaplessGapPrevEndedAtNs = 0
3240
+ self.gaplessGapMeasuredAtNs = 0
3241
+ #if DEBUG
3242
+ self.gaplessLogLastChangedAtNs = now
3243
+ NSLog("[RNQP-GAPLESS] item-changed trackIdx=%d ts_ns=%llu delta_since_ended_ms=%.3f",
3244
+ idx, now, transitionGapMs ?? 0)
3245
+ #endif
3246
+ }
3247
+ // Cross-platform listener-fire order: track-change first so the
3248
+ // consumer's track-keyed handlers run before the format-refresh
3249
+ // emits null. Matches Android's onMediaItemTransition →
3250
+ // refreshNowPlayingFormatForActiveItem ordering.
3251
+ trackChangeListeners.forEach { $0(track, Double(idx), reason, transitionGapMs) }
3252
+ // Mirror the active track into a static cache so SiriKit intent
3253
+ // handlers can read a "current track" seed without crossing back
3254
+ // into JS. Nil-out happens on queue-end via the same dispatch
3255
+ // path (track == nil).
3256
+ TrackPlayer.cachedCurrentTrack = track
3257
+ self.refreshNowPlayingFormatForActiveItem()
3258
+ self.nowPlayingInfo.refreshAll()
3259
+ // Kick the network resolve. The placeholder is already on the
3260
+ // lock-screen via refreshAll; the resolver swaps in the real
3261
+ // bitmap when the fetch lands. ArtworkResolver's single in-
3262
+ // flight + last-URL guard drops a stale fetch if another track
3263
+ // change fires before completion.
3264
+ self.artworkResolver.resolve(url: track?.artworkUrl) { [weak self] image in
3265
+ self?.nowPlayingInfo.applyArtwork(image)
3266
+ }
3267
+ }
3268
+ }
3269
+
3270
+ /// Classify the active-track playback source. `nil` when the URL
3271
+ /// is nil or its scheme is neither `file://` nor `http(s)://`
3272
+ /// (e.g. `data:`, `blob:`, custom schemes — the lib has no contract
3273
+ /// to honour for those today). `http(s)://` URLs split on whether
3274
+ /// `isUrlCached` returns true (`.cached`) or false (`.streaming`).
3275
+ /// `isUrlCached` is invoked at most once, only for `http(s)://` URLs.
3276
+ internal static func classifyTrackSource(
3277
+ url: String?, isUrlCached: (String) -> Bool
3278
+ ) -> TrackSource? {
3279
+ guard let url = url else { return nil }
3280
+ if url.hasPrefix("file://") { return .local }
3281
+ let isHttp = url.hasPrefix("http://") || url.hasPrefix("https://")
3282
+ guard isHttp else { return nil }
3283
+ return isUrlCached(url) ? .cached : .streaming
3284
+ }
3285
+
3286
+ /// Queue-end detection — fires only when AVQueuePlayer has
3287
+ /// auto-advanced past the last item: `currentItem == nil`,
3288
+ /// `!tracks.isEmpty`, no mutation in flight. Under `.queue`
3289
+ /// repeat mode synthesises a wrap-to-0 (mirroring Android
3290
+ /// REPEAT_MODE_ALL); otherwise fires `queueEndListeners` + emits
3291
+ /// `.ended` with reason `.queueEnd`.
3292
+ ///
3293
+ /// The wrap path resets `lastEmittedQueueItemId` so the
3294
+ /// single-track .queue case re-fires `onTrackChange` (the
3295
+ /// queueItemId at idx 0 is unchanged across the wrap; without
3296
+ /// this clear the next dispatch would dedup-swallow the
3297
+ /// re-emission).
3298
+ private func detectQueueEnd() {
3299
+ // Engine-aware "is the active item nil" check. With
3300
+ // CrossfadeEngine `self.player` is nil by design — reading it
3301
+ // alone would mis-detect every mid-play engine swap as
3302
+ // queue-end and fire a spurious onQueueEnd + emitState(.ended).
3303
+ let activeItemNil: Bool
3304
+ if self.player != nil {
3305
+ activeItemNil = self.player?.currentItem == nil
3306
+ } else if let engine = self.engine {
3307
+ activeItemNil = engine.currentMediaItem == nil
3308
+ } else {
3309
+ activeItemNil = true
3310
+ }
3311
+ guard activeItemNil && !self.tracks.isEmpty else { return }
3312
+ if self.repeatModeState == .queue, self.player != nil {
3313
+ self.currentTrackIndex = 0
3314
+ self.pendingTrackChangeReason = .autoAdvance
3315
+ self.lastEmittedQueueItemId = nil
3316
+ self.performingMutation { self.fullRebuildPlayerQueue() }
3317
+ } else {
3318
+ self.reachedQueueEnd = true
3319
+ queueEndListeners.forEach { $0() }
3320
+ emitState(.ended, reason: .queueEnd)
3321
+ }
3322
+ }
3323
+
3324
+ /// Fires from the `addPeriodicTimeObserver` tick (gapless mode)
3325
+ /// or the engine-agnostic `progressFallbackTimer` (crossfade
3326
+ /// mode, where `self.player` is nil). Reports current position +
3327
+ /// duration + seconds-buffered-ahead-of-position.
3328
+ ///
3329
+ /// The two paths source position differently: gapless reads off
3330
+ /// `self.player.currentItem` directly; crossfade reads off
3331
+ /// `engine.currentPositionSeconds` / `currentDurationSeconds` /
3332
+ /// `bufferedPositionSeconds` (computed properties on the
3333
+ /// engine surface that delegate to whichever leg is leading).
3334
+ private func emitProgressIfSubscribed() {
3335
+ guard !progressListeners.isEmpty else { return }
3336
+ let position: TimeInterval
3337
+ let duration: TimeInterval
3338
+ let buffered: TimeInterval
3339
+ // Whether the playhead is actively advancing. The throttle applies only
3340
+ // while playing (the streaming firehose); a paused player's ticks are
3341
+ // discrete (seek / stop / repeat-rewind) and must always deliver so the UI
3342
+ // reflects the move — otherwise a paused seek under a >500ms interval is
3343
+ // lost until playback resumes.
3344
+ let isPlaying: Bool
3345
+ if let item = self.player?.currentItem {
3346
+ position = CMTimeGetSeconds(item.currentTime())
3347
+ let rawDuration = CMTimeGetSeconds(item.duration)
3348
+ duration = (rawDuration.isFinite && rawDuration > 0) ? rawDuration : 0.0
3349
+ buffered = loadedAheadSeconds(for: item, currentPosition: position)
3350
+ isPlaying = (self.player?.rate ?? 0) != 0
3351
+ } else if let engine = self.engine {
3352
+ // Engine-driven path (CrossfadeEngine): the fallback Timer fires
3353
+ // unconditionally. While paused, emit ONLY when the playhead moved since
3354
+ // the last emit (a seek / stop / repeat-rewind), mirroring the gapless
3355
+ // periodic observer which fires once on seek completion even while
3356
+ // paused — otherwise the post-stop "position reset to 0" tick never
3357
+ // reaches consumers. A steady pause (position unchanged) still skips, so
3358
+ // identical tuples aren't re-published every 0.5s.
3359
+ position = engine.currentPositionSeconds
3360
+ // Qualify Swift.abs: a bare `abs(...)` is ambiguous against Darwin's
3361
+ // C `abs` when that's in scope (the library build context), so name
3362
+ // the Swift overload explicitly — matches BiquadCoefficients.
3363
+ let playheadDelta = Swift.abs(position - self.lastEmittedCrossfadePosition)
3364
+ let moved = playheadDelta > 0.001
3365
+ guard engine.isPlaying || moved else { return }
3366
+ self.lastEmittedCrossfadePosition = position
3367
+ duration = engine.currentDurationSeconds
3368
+ let bufferedAbs = engine.bufferedPositionSeconds
3369
+ buffered = max(0, bufferedAbs - position)
3370
+ isPlaying = engine.isPlaying
3371
+ } else {
3372
+ return
3373
+ }
3374
+ let safePosition = position.isFinite ? position : 0.0
3375
+ let safeDuration = duration.isFinite ? duration : 0.0
3376
+ let progress = PlayerProgress(
3377
+ position: safePosition, duration: safeDuration, buffered: buffered)
3378
+ // Throttle only the playing firehose; paused ticks (discrete seek/stop
3379
+ // results) always deliver. The crossfade movement bookkeeping above already
3380
+ // ran this tick, and milestones + buffer state are separate calls, so both
3381
+ // stay full-rate regardless of the throttle.
3382
+ guard !isPlaying || self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else {
3383
+ return
3384
+ }
3385
+ progressListeners.forEach { $0(progress) }
3386
+ }
3387
+
3388
+ /// Advance the milestone tracker off the same periodic tick and emit any
3389
+ /// 25/50/75/90% thresholds forward playback just crossed. Runs the
3390
+ /// tracker even with no subscribers (so a mid-stream subscribe doesn't
3391
+ /// retroactively fire already-passed thresholds); emission is gated on
3392
+ /// having listeners.
3393
+ private func processMilestones() {
3394
+ // No active track => nothing to attribute a milestone to (mirrors the
3395
+ // Android guard; avoids emitting with trackIndex -1 on a stale read
3396
+ // during a clear / queue-end transition).
3397
+ guard !self.tracks.isEmpty, self.currentTrackIndex >= 0 else { return }
3398
+ let position: TimeInterval
3399
+ let playerDuration: TimeInterval
3400
+ if let item = self.player?.currentItem {
3401
+ position = CMTimeGetSeconds(item.currentTime())
3402
+ let raw = CMTimeGetSeconds(item.duration)
3403
+ playerDuration = (raw.isFinite && raw > 0) ? raw : 0
3404
+ } else if let engine = self.engine {
3405
+ position = engine.currentPositionSeconds
3406
+ playerDuration = engine.currentDurationSeconds
3407
+ } else {
3408
+ return
3409
+ }
3410
+ guard position.isFinite else { return }
3411
+ let duration = effectiveMilestoneDuration(playerDuration: playerDuration)
3412
+ let crossed = milestoneTracker.tick(durationSec: duration, positionSec: position)
3413
+ guard !crossed.isEmpty, !milestoneListeners.isEmpty else { return }
3414
+ let idx = Double(self.currentTrackIndex)
3415
+ for m in crossed {
3416
+ milestoneListeners.forEach { $0(Double(m), idx) }
3417
+ }
3418
+ }
3419
+
3420
+ /// Player-reported duration when known (> 0), else the consumer-supplied
3421
+ /// `TrackItem.duration` (seconds) for the active track, else 0 (no
3422
+ /// computable duration → no milestones).
3423
+ private func effectiveMilestoneDuration(playerDuration: TimeInterval) -> TimeInterval {
3424
+ if playerDuration > 0 { return playerDuration }
3425
+ let idx = self.currentTrackIndex
3426
+ if idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 {
3427
+ return d
3428
+ }
3429
+ return 0
3430
+ }
3431
+
3432
+ /// Map an `AVPlayerItem` back to an index in `self.tracks`.
3433
+ ///
3434
+ /// Lookup is keyed on the lib-generated `queueItemId`
3435
+ /// associated-object set by `AVQueueBuilder.makeItem` at
3436
+ /// construction. `self.queueItemIds` is a parallel `[String]`
3437
+ /// array maintained 1:1 with `self.tracks` at every queue
3438
+ /// mutation site; `firstIndex(of:)` resolves the AVPlayerItem's
3439
+ /// `queueItemId` to its position in O(N) — N is queue size,
3440
+ /// typical < 100 tracks, no memoization warranted.
3441
+ ///
3442
+ /// queueItemId is unique per construction, so each AVPlayerItem
3443
+ /// maps to exactly one queue position even when the consumer
3444
+ /// supplies the same `track.url` at multiple positions.
3445
+ ///
3446
+ /// Returns nil when `item` is nil, when the item lacks a
3447
+ /// queueItemId (e.g. constructed outside `AVQueueBuilder` —
3448
+ /// shouldn't happen in production), or when the queueItemId
3449
+ /// isn't in `self.queueItemIds` (post-mutation desync — also
3450
+ /// shouldn't happen, but defensive). The caller leaves
3451
+ /// `currentTrackIndex` unchanged on a nil result.
3452
+ private func matchTrackIndex(forCurrentItem item: AVPlayerItem?) -> Int? {
3453
+ guard let id = item?.queueItemId else { return nil }
3454
+ return self.queueItemIds.firstIndex(of: id)
3455
+ }
3456
+
3457
+ /// Compute "seconds buffered ahead of position" — consumers use
3458
+ /// this for the "buffered track" overlay on a progress bar. Takes
3459
+ /// the end of the last loaded `CMTimeRange`, subtracts the current
3460
+ /// position, and clamps to zero. Returns 0 when no ranges are
3461
+ /// loaded or when the subtraction is non-finite.
3462
+ private func loadedAheadSeconds(
3463
+ for item: AVPlayerItem, currentPosition: Double
3464
+ ) -> Double {
3465
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return 0 }
3466
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
3467
+ guard end.isFinite, currentPosition.isFinite else { return 0 }
3468
+ return max(0, end - currentPosition)
3469
+ }
3470
+
3471
+ /// Recompute the current buffer state from the active engine item, advance
3472
+ /// the per-track buffered high-water mark, and fire `onBufferStateChange`
3473
+ /// when the discrete state actually changes. Runs on the buffer KVO, each
3474
+ /// progress tick, the transport methods, and at track change.
3475
+ ///
3476
+ /// `.full` = keeps up (`playbackLikelyToKeepUp` / `playbackBufferFull`).
3477
+ /// A not-full loaded item is `.stalled` when playback had started and is
3478
+ /// intended (a mid-play rebuffer) or `.buffering` otherwise (initial load /
3479
+ /// loading while paused). `.empty` means nothing is loaded to play.
3480
+ private func recomputeBufferState() {
3481
+ let computed: BufferState
3482
+ if let item = self.engine?.currentMediaItem {
3483
+ if item.isPlaybackLikelyToKeepUp || item.isPlaybackBufferFull {
3484
+ computed = .full
3485
+ } else {
3486
+ computed = (self.hasStartedPlaying && self.wantsToPlay) ? .stalled : .buffering
3487
+ }
3488
+ } else {
3489
+ computed = .empty
3490
+ }
3491
+ self.currentBufferState = computed
3492
+ guard computed != self.lastEmittedBufferState else { return }
3493
+ self.lastEmittedBufferState = computed
3494
+ bufferStateListeners.forEach { $0(computed) }
3495
+ }
3496
+
3497
+ /// The whole track has downloaded — the loaded range has reached the track's
3498
+ /// duration. When the player can't report a duration (e.g. a transcoded
3499
+ /// stream) it falls back to the consumer-supplied `track.duration`, the same
3500
+ /// fallback milestones use. A live / indefinite source never completes.
3501
+ /// (`isPlaybackBufferFull` is deliberately NOT used — it means the fixed
3502
+ /// buffer is full, not that the whole track has downloaded.)
3503
+ private func computeFullyBuffered() -> Bool {
3504
+ guard let item = self.engine?.currentMediaItem else { return false }
3505
+ if item.duration.isIndefinite { return false }
3506
+ // A local file or a fully-cached source is entirely on disk — fully
3507
+ // available regardless of the playback buffer.
3508
+ if self.currentTrackSource == .local || self.currentTrackSource == .cached { return true }
3509
+ var dur = CMTimeGetSeconds(item.duration)
3510
+ if !dur.isFinite || dur <= 0 {
3511
+ let idx = self.currentTrackIndex
3512
+ guard idx >= 0, idx < self.tracks.count, let d = self.tracks[idx].duration, d > 0 else {
3513
+ return false
3514
+ }
3515
+ dur = d
3516
+ }
3517
+ guard let last = item.loadedTimeRanges.last?.timeRangeValue else { return false }
3518
+ let end = CMTimeGetSeconds(CMTimeAdd(last.start, last.duration))
3519
+ return end.isFinite && end >= dur - Self.fullyBufferedEpsilonSeconds
3520
+ }
3521
+
3522
+ /// Recompute fully-buffered and fire `onFullyBufferedChange` on a change.
3523
+ /// Runs on the buffer-full KVO, the progress tick (the loaded range grows),
3524
+ /// and at track change.
3525
+ private func recomputeFullyBuffered() {
3526
+ let computed = computeFullyBuffered()
3527
+ self.currentFullyBuffered = computed
3528
+ guard computed != self.lastEmittedFullyBuffered else { return }
3529
+ self.lastEmittedFullyBuffered = computed
3530
+ fullyBufferedListeners.forEach { $0(computed) }
3531
+ }
3532
+
3533
+ /// Cap `target` at the current buffered extent when `clampSeekToBuffered`
3534
+ /// is enabled and the target is past what has buffered. A local or fully-
3535
+ /// buffered track reports `buffered >= duration >= target`, so nothing is
3536
+ /// clamped and a seek reaches the end freely; a partly-buffered stream caps
3537
+ /// at the downloaded edge. Reads the live buffered position (not a tracked
3538
+ /// mark) so a seek-back that evicted the ahead-buffer clamps to what's there.
3539
+ private func clampToBufferedIfEnabled(_ target: Double) -> Double {
3540
+ guard self.config.clampSeekToBuffered == true else { return target }
3541
+ guard let buffered = self.engine?.bufferedPositionSeconds, buffered.isFinite, buffered > 0 else {
3542
+ return target
3543
+ }
3544
+ return target > buffered ? buffered : target
3545
+ }
3546
+
3547
+ /// Translate `AVPlayer.timeControlStatus` into our `PlayerState`
3548
+ /// enum and fire `onStateChange` when the value actually changed.
3549
+ /// Consumes `pendingStateChangeReason` (set by transport methods
3550
+ /// before they call `play`/`pause`/`stop`, or by the AudioSession
3551
+ /// handlers for interruption/route-change events) so user-
3552
+ /// initiated transitions are labelled correctly while purely
3553
+ /// system-internal ones default to `.system`.
3554
+ ///
3555
+ /// `.buffering` is a system-internal intermediate state — it
3556
+ /// shows up between `.paused` → `.playing` while AVPlayer fills
3557
+ /// its buffer. We deliberately do NOT consume the pending reason
3558
+ /// for `.buffering` so the reason survives to the terminal
3559
+ /// `.playing` emit. Otherwise a play() after interruption would
3560
+ /// label buffering `.interruption` and the actual `.playing` as
3561
+ /// `.system`, which consumers won't expect.
3562
+ private func emitStateChangeIfChanged() {
3563
+ guard let engine = self.engine else { return }
3564
+ let computed = PlayerStateDerivation.translate(
3565
+ status: engine.timeControlStatus,
3566
+ currentIndex: self.currentTrackIndex,
3567
+ hasCurrentItem: engine.currentMediaItem != nil,
3568
+ reachedQueueEnd: self.reachedQueueEnd
3569
+ )
3570
+ emitState(computed, reason: reasonForComputedState(computed))
3571
+ }
3572
+
3573
+ /// Reason for a derived `PlayerState`, shared by the gapless KVO
3574
+ /// (`emitStateChangeIfChanged`) and the crossfade delegate
3575
+ /// (`engineStateMaybeChanged`) so both label states identically.
3576
+ /// `.buffering` is intermediate — pass through as `.system` and leave the
3577
+ /// pending reason for the terminal emit. `.ended` is always end-of-queue —
3578
+ /// stamp `.queueEnd` to match `detectQueueEnd` / `enginePlaybackEnded`.
3579
+ private func reasonForComputedState(_ computed: PlayerState) -> StateChangeReason {
3580
+ if computed == .buffering {
3581
+ return .system
3582
+ }
3583
+ if computed == .ended {
3584
+ self.pendingStateChangeReason = nil
3585
+ return .queueEnd
3586
+ }
3587
+ let reason = self.pendingStateChangeReason ?? .system
3588
+ self.pendingStateChangeReason = nil
3589
+ return reason
3590
+ }
3591
+
3592
+ /// Bypass the timeControlStatus translation and emit a specific
3593
+ /// state directly (used for `.ended` / `.error` which AVPlayer
3594
+ /// doesn't model as timeControlStatus).
3595
+ private func emitState(_ state: PlayerState, reason: StateChangeReason) {
3596
+ // Playback (re)starting clears the end-of-queue latch — do this BEFORE the
3597
+ // dedup guard so a redundant `.playing` re-emit still clears it.
3598
+ if state == .playing { self.reachedQueueEnd = false }
3599
+ guard state != self.lastReportedState else { return }
3600
+ self.lastReportedState = state
3601
+ // Once the track actually plays, a later not-full buffer is a mid-playback
3602
+ // `stalled`, not the initial `buffering` load.
3603
+ if state == .playing { self.hasStartedPlaying = true }
3604
+ self.stateChangeListeners.forEach { $0(state, reason) }
3605
+ self.nowPlayingInfo.refreshPositionAndRate()
3606
+ }
3607
+
3608
+ /// Internal entry point for `CastEventBridge` to fan receiver-driven
3609
+ /// state into the JS-facing event surface. Goes through the same
3610
+ /// `emitState` dedup chokepoint as local-engine transitions, so JS
3611
+ /// sees one event per real receiver transition regardless of source.
3612
+ func emitRemoteState(_ state: PlayerState, reason: StateChangeReason) {
3613
+ self.emitState(state, reason: reason)
3614
+ }
3615
+
3616
+ /// Fan a cast receiver's position update to JS `onProgress` so the
3617
+ /// consumer's player screen (progress bar / position / duration) tracks
3618
+ /// the receiver while a remote session is active. The receiver reports
3619
+ /// milliseconds; `PlayerProgress` is in seconds. Buffered arrives as an
3620
+ /// absolute receiver position, so it's reduced to "ahead of position"
3621
+ /// to match the local path's semantics. The local now-playing / format
3622
+ /// path is untouched — the local engine is silent during cast.
3623
+ func emitRemoteProgress(positionMs: Int64, durationMs: Int64, bufferedMs: Int64) {
3624
+ guard !progressListeners.isEmpty else { return }
3625
+ // Same throttle as the local tick, shared instance — a consumer's
3626
+ // configured cadence caps cast progress too (the receiver pushes at its own
3627
+ // rate, so the effective cadence is min(receiver rate, configured interval)).
3628
+ guard self.progressEmissionGate.shouldEmit(nowMs: self.monotonicNowMs()) else { return }
3629
+ let position = TimeInterval(max(0, positionMs)) / 1000.0
3630
+ let duration = TimeInterval(max(0, durationMs)) / 1000.0
3631
+ let buffered = TimeInterval(max(0, bufferedMs - positionMs)) / 1000.0
3632
+ let progress = PlayerProgress(position: position, duration: duration, buffered: buffered)
3633
+ progressListeners.forEach { $0(progress) }
3634
+ }
3635
+
3636
+ /// Fan a cast receiver's track advance to JS `onTrackChange` and update
3637
+ /// the authoritative `currentTrackIndex` so the consumer's player screen
3638
+ /// follows the receiver's current track. Mirrors the local dispatch's
3639
+ /// listener fire without the local-engine now-playing refresh (the cast
3640
+ /// lockscreen is driven separately via `CastNowPlayingController`).
3641
+ /// Called on the main thread from `CastEventBridge`, like `emitRemoteState`.
3642
+ func emitRemoteTrackChange(index: Int) {
3643
+ guard index >= 0, index < self.tracks.count else { return }
3644
+ self.currentTrackIndex = index
3645
+ let track = self.tracks[index]
3646
+ self.trackChangeListeners.forEach { $0(track, Double(index), .autoAdvance, nil) }
3647
+ // `currentTrackIndex` just moved — recompute skip capability so the
3648
+ // receiver's advance updates canSkipPrevious/Next (skip-back enables
3649
+ // once off the first track), same as every local index write.
3650
+ self.recomputeCapabilities()
3651
+ }
3652
+
3653
+ /// Hand off current local playback to a newly-connected cast receiver:
3654
+ /// load the current queue at the current playhead with the current
3655
+ /// play/pause, so playback continues where it was. The local engine is
3656
+ /// silenced immediately (after the state read) so the device speaker
3657
+ /// never overlaps the receiver. Async — the receiver load is a
3658
+ /// round-trip; a failed load leaves local paused (never blasts audio).
3659
+ func handoffCurrentPlaybackToCast() {
3660
+ // Capture + pause synchronously (the cast-active transition fires on
3661
+ // the main thread) so the resume seed lands before the async load
3662
+ // below — a disconnect during the load round-trip then restores the
3663
+ // captured local state instead of being left paused.
3664
+ let snapshot = onMainSync { () -> HandoffSnapshot? in
3665
+ let tracks = self.tracks
3666
+ let idx = self.currentTrackIndex
3667
+ let positionMs = Int64(max(0, (self.engine?.currentPositionSeconds ?? 0)) * 1000.0)
3668
+ let playing = self.engine?.isPlaying ?? false
3669
+ self.engine?.pause()
3670
+ guard idx >= 0, idx < tracks.count else { return nil }
3671
+ let items = tracks.compactMap { CastMediaItem.from(track: $0) }
3672
+ guard items.count == tracks.count else { return nil }
3673
+ return HandoffSnapshot(
3674
+ items: items, startIndex: idx, positionMs: positionMs, playing: playing
3675
+ )
3676
+ }
3677
+ guard let snapshot else { return }
3678
+ castEventBridge.seedDeactivationSnapshot(
3679
+ trackIndex: snapshot.startIndex,
3680
+ positionMs: snapshot.positionMs
3681
+ )
3682
+ Task { [weak self] in
3683
+ guard let self = self else { return }
3684
+ let ids = try? await CastTransportRouter.routeSetQueue(
3685
+ tracks: snapshot.items,
3686
+ startIndex: snapshot.startIndex,
3687
+ startPositionMs: snapshot.positionMs,
3688
+ playWhenReady: snapshot.playing
3689
+ )
3690
+ // A non-nil result means the receiver load succeeded; seed the start
3691
+ // track's lockscreen metadata. The current track is then tracked via
3692
+ // the receiver's absolute queue index, not these itemIds.
3693
+ if ids != nil {
3694
+ await self.onMain {
3695
+ self.castEventBridge.seedStartMetadata(startIndex: snapshot.startIndex)
3696
+ }
3697
+ }
3698
+ }
3699
+ }
3700
+
3701
+ private struct HandoffSnapshot {
3702
+ let items: [CastMediaItem]
3703
+ let startIndex: Int
3704
+ let positionMs: Int64
3705
+ let playing: Bool
3706
+ }
3707
+
3708
+ /// Return local playback to the receiver's last track + position after
3709
+ /// a cast session ends. The local engine kept the mirrored queue
3710
+ /// (paused) during cast, so this seeks it to the handback point and
3711
+ /// stays PAUSED — the user resumes locally. Never auto-blasts audio out
3712
+ /// of the phone on disconnect. A `-1` trackIndex (receiver index never
3713
+ /// resolved) is a no-op; local stays where it was.
3714
+ func resumeLocalAfterCast(trackIndex: Int, positionMs: Int64) {
3715
+ onMainSync {
3716
+ guard trackIndex >= 0, trackIndex < self.tracks.count else { return }
3717
+ let positionSec = max(0, Double(positionMs) / 1000.0)
3718
+ self.currentTrackIndex = trackIndex
3719
+ // Rebuild the local engine from the resumed absolute index. The
3720
+ // engine was frozen at the cast-start index for the whole cast
3721
+ // session, so its queue no longer matches `tracks[trackIndex...]`;
3722
+ // a raw `engine.seek(toIndex: trackIndex)` would index the
3723
+ // GaplessEngine's remaining-queue space (not the lib's absolute
3724
+ // index) and seat the wrong item, leaving a later skipToIndex /
3725
+ // play desynced from `currentTrackIndex`. The rebuild re-seats the
3726
+ // current item at engine-index 0 so transport stays consistent.
3727
+ self.performingMutation { self.fullRebuildPlayerQueue() }
3728
+ // Seek within the freshly-seated current item to the receiver's
3729
+ // last position (engine-index 0 is the current item post-rebuild).
3730
+ if positionSec > 0 {
3731
+ self.engine?.seek(toIndex: 0, position: positionSec)
3732
+ }
3733
+ // Stay paused — the user resumes locally; never auto-blast the
3734
+ // phone speaker on revert.
3735
+ self.engine?.pause()
3736
+ self.pendingStateChangeReason = self.pendingStateChangeReason ?? .user
3737
+ self.rescheduleLookahead()
3738
+ self.recomputeCapabilities()
3739
+ }
3740
+ }
3741
+
3742
+ /// Real end-of-track signal. AVPlayerItemDidPlayToEndTime fires
3743
+ /// when an item finishes playback naturally — NOT when
3744
+ /// AVQueuePlayer chain-advances through unbuffered items during a
3745
+ /// rebuild. The lib uses this distinction to:
3746
+ ///
3747
+ /// - Natural auto-advance: notification fires → set
3748
+ /// `didPlayToEndPending` → `handleCurrentItemDidChange` runs
3749
+ /// `matchTrackIndex` resync → `currentTrackIndex` updates to
3750
+ /// the auto-advanced position.
3751
+ /// - Chain-advance bounce: notification does NOT fire (items
3752
+ /// weren't actually playing). `\.currentItem` KVO still fires
3753
+ /// from the rebuild's insert sequence, but
3754
+ /// `handleCurrentItemDidChange`'s gate sees
3755
+ /// `didPlayToEndPending == false` and skips the resync —
3756
+ /// `currentTrackIndex` stays at whatever the user mutation
3757
+ /// pre-wrote.
3758
+ ///
3759
+ /// Repeat-track handling: when `repeatModeState == .track`, seek
3760
+ /// the just-finished item back to zero + replay. The notification
3761
+ /// fires BEFORE AVQueuePlayer auto-advances (per Apple docs), so
3762
+ /// we get the chance to rewind without flashing through the next
3763
+ /// item. We do NOT set `didPlayToEndPending` in this branch —
3764
+ /// the seek-to-zero produces no `\.currentItem` change, so there
3765
+ /// is no auto-advance to gate.
3766
+ @objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
3767
+ DispatchQueue.main.async { [weak self] in
3768
+ guard let self else { return }
3769
+ // Branch on repeat-track FIRST, separately from the
3770
+ // currentItem === item check. The notification dispatches
3771
+ // async to main but AVQueuePlayer
3772
+ // runs `actionAtItemEnd = .advance` synchronously on its own
3773
+ // queue — under load, the player can have already advanced
3774
+ // by the time our async block runs. Falling through to the
3775
+ // auto-advance branch in repeat-track mode would incorrectly
3776
+ // arm `didPlayToEndPending`; the resync would then run on
3777
+ // the unintended-advance currentItem and clobber the user's
3778
+ // expected repeat-track stay-on-current.
3779
+ if self.repeatModeState == .track {
3780
+ // Repeat-track: rewind the just-finished item + replay.
3781
+ // Apple's docs note the notification fires BEFORE
3782
+ // actionAtItemEnd is honoured, so the seek-to-zero +
3783
+ // play normally beats the auto-advance.
3784
+ if let player = self.player {
3785
+ // Repeat-one loop = a new milestone playthrough of the same track.
3786
+ // The gapless engine fires no track-change / delegate here, so
3787
+ // dispatchTrackChange's reset is skipped — reset directly, and
3788
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), not
3789
+ // only when the rewind race below is won: under load this async block
3790
+ // can run AFTER AVQueuePlayer already chain-advanced, in which case
3791
+ // the seek is skipped but the track still loops, so milestones must
3792
+ // re-arm regardless. (CrossfadeEngine has self.player == nil here and
3793
+ // resets via engineDidPlayItemToEnd instead — no double reset.)
3794
+ self.milestoneTracker.reset()
3795
+ if let item = notification.object as? AVPlayerItem,
3796
+ player.currentItem === item {
3797
+ item.seek(to: .zero, completionHandler: nil)
3798
+ // Replay through the engine so the user's playback speed is
3799
+ // restored (a raw AVPlayer.play() resets rate to 1.0).
3800
+ self.engine?.play()
3801
+ }
3802
+ }
3803
+ // If the rewind window was lost (player chain-advanced
3804
+ // before our async block ran), accept that the
3805
+ // KVO fire will re-sync currentTrackIndex via the next
3806
+ // currentItem change — but DO NOT arm `didPlayToEndPending`
3807
+ // here, because in repeat-track mode the auto-advance
3808
+ // shouldn't have happened. The resync via KVO without
3809
+ // didPlayToEndPending stays correct because user
3810
+ // mutations pre-write the index; nothing overwrites the
3811
+ // shadow index incorrectly.
3812
+ return
3813
+ }
3814
+ // Repeat-off / repeat-queue: arm the resync gate so
3815
+ // `handleCurrentItemDidChange` will run `matchTrackIndex`
3816
+ // when the `\.currentItem` KVO fires for the next item.
3817
+ self.didPlayToEndPending = true
3818
+ let now = DispatchTime.now().uptimeNanoseconds
3819
+ self.gaplessGapPrevEndedAtNs = now
3820
+ #if DEBUG
3821
+ self.gaplessLogLastEndedAtNs = now
3822
+ NSLog("[RNQP-GAPLESS] item-ended trackIdx=%d ts_ns=%llu",
3823
+ self.currentTrackIndex, now)
3824
+ #endif
3825
+ }
3826
+ }
3827
+
3828
+ /// New `AVPlayerItem` access-log entries arrive multiple times per
3829
+ /// second during streaming (per network transfer chunk). Two
3830
+ /// concerns share the handler: the streaming-MP3 bitrate fallback
3831
+ /// (re-resolves the now-playing format when the cache currently has
3832
+ /// no bitrate) and DEBUG-only timeline logging.
3833
+ ///
3834
+ /// The fallback dedup key is the cached format's `bitrate` field —
3835
+ /// once a non-null value lands, subsequent access-log fires for the
3836
+ /// same item don't trigger a re-resolve. The active-item gate
3837
+ /// (`item === self.player?.currentItem`) drops fires from items the
3838
+ /// AVQueuePlayer has already advanced past; the
3839
+ /// `nowPlayingFormatRequestGen` token inside
3840
+ /// `refreshNowPlayingFormatForActiveItem` provides a second line of
3841
+ /// defence against a stale resolve overwriting a newer item's format.
3842
+ @objc private func handleNewAccessLogEntry(_ notification: Notification) {
3843
+ DispatchQueue.main.async { [weak self] in
3844
+ guard let self,
3845
+ let item = notification.object as? AVPlayerItem else { return }
3846
+ if item === self.activeMediaItem,
3847
+ self.lastEmittedFormatItemId == ObjectIdentifier(item),
3848
+ let format = self.lastEmittedFormat,
3849
+ format.codec == .mp3 {
3850
+ let hasBitrate: Bool = {
3851
+ guard let variant = format.bitrate,
3852
+ case .second = variant else { return false }
3853
+ return true
3854
+ }()
3855
+ if !hasBitrate {
3856
+ self.reresolveNowPlayingFormatForBitrate()
3857
+ }
3858
+ }
3859
+ #if DEBUG
3860
+ guard let event = item.accessLog()?.events.last else { return }
3861
+ let now = DispatchTime.now().uptimeNanoseconds
3862
+ let deltaSinceChangedMs = self.gaplessLogLastChangedAtNs > 0
3863
+ ? (now &- self.gaplessLogLastChangedAtNs) / 1_000_000 : 0
3864
+ NSLog("[RNQP-GAPLESS] access-log trackIdx=%d stalls=%ld observedBitrate=%lld transferDurationMs=%d numberOfMediaRequests=%ld delta_since_changed_ms=%llu",
3865
+ self.currentTrackIndex,
3866
+ event.numberOfStalls,
3867
+ Int64(event.observedBitrate),
3868
+ Int(event.transferDuration * 1000),
3869
+ event.numberOfMediaRequests,
3870
+ deltaSinceChangedMs)
3871
+ #endif
3872
+ }
3873
+ }
3874
+
3875
+ #if DEBUG
3876
+ /// Error-log entries surface alongside the existing
3877
+ /// `AVPlayerItemFailedToPlayToEndTime` notification for a richer
3878
+ /// diagnostic record. Compiled out in Release builds.
3879
+ @objc private func handleNewErrorLogEntry(_ notification: Notification) {
3880
+ DispatchQueue.main.async { [weak self] in
3881
+ guard let self,
3882
+ let item = notification.object as? AVPlayerItem,
3883
+ let log = item.errorLog(),
3884
+ let event = log.events.last else { return }
3885
+ NSLog("[RNQP-GAPLESS] error-log trackIdx=%d errorDomain=%@ errorCode=%ld errorComment=%@",
3886
+ self.currentTrackIndex,
3887
+ event.errorDomain,
3888
+ event.errorStatusCode,
3889
+ event.errorComment ?? "nil")
3890
+ }
3891
+ }
3892
+ #endif
3893
+
3894
+ @objc private func handlePlayerItemFailedToPlayToEndTime(_ notification: Notification) {
3895
+ DispatchQueue.main.async { [weak self] in
3896
+ guard let self else { return }
3897
+ let underlying = notification.userInfo?[
3898
+ AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
3899
+ // Mid-play failures share the same dedup + retry path as
3900
+ // load failures — both routed through `dispatchErrorOrRetry`.
3901
+ // Real-world repro showed 4× identical AVFoundation "Cannot
3902
+ // Open" fires from this notification firing repeatedly when
3903
+ // the player is in a wedged state; the dedup gate suppresses
3904
+ // them, the retry path attempts recovery before surfacing.
3905
+ let item = notification.object as? AVPlayerItem ?? self.player?.currentItem
3906
+ self.dispatchErrorOrRetry(item: item, underlying: underlying)
3907
+ }
3908
+ }
3909
+
3910
+ /// Construct a richly-populated `PlaybackError` from an underlying
3911
+ /// `NSError` + the already-mapped standardized code. Centralised so
3912
+ /// every iOS error fire site produces the same shape (sanitised
3913
+ /// `nativeMessage`, consistent `fatal` classifier, etc.). Mirrors
3914
+ /// `PlaybackErrorMapping.kt#buildPlaybackError` on Android.
3915
+ ///
3916
+ /// `nativeDomainOverride` replaces the underlying `NSError.domain`
3917
+ /// on the emitted error — used by lib-defined error categories
3918
+ /// (`PLAYER_ITEM_LOAD_FAILED`, `AUDIO_SESSION_*`,
3919
+ /// `AUDIO_SESSION_INTERRUPTION_*`) that need a stable
3920
+ /// SCREAMING_SNAKE_CASE marker for JS branching. Nil keeps the raw
3921
+ /// underlying domain for callers that prefer the native string.
3922
+ internal func buildPlaybackError(
3923
+ _ underlying: NSError?,
3924
+ mapped: PlaybackErrorCode,
3925
+ queueItemId: String = "",
3926
+ url: String = "",
3927
+ nativeDomainOverride: String? = nil
3928
+ ) -> PlaybackError {
3929
+ let nativeCode = underlying?.code ?? 0
3930
+ let nativeDomain = nativeDomainOverride ?? (underlying?.domain ?? "")
3931
+ let nativeMessage = PlaybackErrorMapping.stripQuery(
3932
+ underlying?.localizedDescription ?? ""
3933
+ )
3934
+ return PlaybackError(
3935
+ code: mapped,
3936
+ message: PlaybackErrorMapping.messageFor(mapped),
3937
+ fatal: !PlaybackErrorMapping.isTransient(mapped),
3938
+ nativeCode: Double(nativeCode),
3939
+ nativeDomain: nativeDomain,
3940
+ nativeMessage: nativeMessage,
3941
+ queueItemId: queueItemId,
3942
+ url: url
3943
+ )
3944
+ }
3945
+
3946
+ // MARK: - Auto-retry + stuck-recovery
3947
+
3948
+ /// Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
3949
+ /// defaults to 3, clamps to [0, 5]. Called from `setQueue` /
3950
+ /// `addToQueueBody` to populate `retryAttemptsRemaining`.
3951
+ ///
3952
+ /// Non-finite (NaN / ±Infinity) inputs from JS would trap at
3953
+ /// `Int(Double.nan)` — Swift's Int initialiser preconditionFails
3954
+ /// rather than rounding to 0 like Kotlin. Mirror Android's
3955
+ /// `coerceIn` behaviour by gating on `isFinite` first.
3956
+ internal func effectiveAutoRetries() -> Int {
3957
+ guard let raw = self.config.autoRetries, raw.isFinite else { return 3 }
3958
+ return max(0, min(5, Int(raw)))
3959
+ }
3960
+
3961
+ /// Lib-clamped retry backoff in milliseconds. Reads from
3962
+ /// `config.retryBackoffMs`, defaults to 500, clamps to [200, 5000].
3963
+ /// Floor at 200ms prevents retry storms against misbehaving servers
3964
+ /// (e.g. 503 with `Retry-After: 0`). Same `isFinite` gate as
3965
+ /// `effectiveAutoRetries`.
3966
+ internal func effectiveRetryBackoffMs() -> Int {
3967
+ guard let raw = self.config.retryBackoffMs, raw.isFinite else { return 500 }
3968
+ return max(200, min(5000, Int(raw)))
3969
+ }
3970
+
3971
+ // MARK: - Progress-emission throttle
3972
+
3973
+ /// Monotonic clock in milliseconds for the progress-emission throttle.
3974
+ /// `systemUptime` is monotonic and needs no extra framework import.
3975
+ private func monotonicNowMs() -> Double {
3976
+ ProcessInfo.processInfo.systemUptime * 1000
3977
+ }
3978
+
3979
+ /// Foreground `onProgress` cadence in ms. Reads
3980
+ /// `config.progressUpdateIntervalMs`, defaults to 500, floors at the 500 ms
3981
+ /// native tick rate, and ignores non-finite / non-positive inputs.
3982
+ private func effectiveForegroundProgressIntervalMs() -> Double {
3983
+ guard let raw = self.config.progressUpdateIntervalMs, raw.isFinite, raw > 0 else { return 500 }
3984
+ return max(500, raw)
3985
+ }
3986
+
3987
+ /// Background `onProgress` cadence in ms. Reads
3988
+ /// `config.backgroundProgressUpdateIntervalMs`, defaults to the effective
3989
+ /// foreground value (⇒ no background reduction), floors at 500, ignores
3990
+ /// non-finite / non-positive inputs.
3991
+ private func effectiveBackgroundProgressIntervalMs() -> Double {
3992
+ let foreground = effectiveForegroundProgressIntervalMs()
3993
+ guard let raw = self.config.backgroundProgressUpdateIntervalMs, raw.isFinite, raw > 0 else {
3994
+ return foreground
3995
+ }
3996
+ return max(500, raw)
3997
+ }
3998
+
3999
+ /// Push the configured foreground + background cadences into the throttle.
4000
+ /// Called on every `configure()`; the gate preserves its runtime state.
4001
+ private func applyProgressEmissionIntervals() {
4002
+ self.progressEmissionGate.setIntervals(
4003
+ foregroundMs: effectiveForegroundProgressIntervalMs(),
4004
+ backgroundMs: effectiveBackgroundProgressIntervalMs(),
4005
+ samplerPeriodMs: Self.progressIntervalSeconds * 1000)
4006
+ }
4007
+
4008
+ /// Observe app background/foreground so the throttle can switch cadence.
4009
+ /// Idempotent — a no-op when already installed. Seeds the current state so a
4010
+ /// `configure()` issued while already backgrounded starts throttled.
4011
+ private func installAppLifecycleObservers() {
4012
+ guard appLifecycleObservers.isEmpty else { return }
4013
+ self.progressEmissionGate.setBackgrounded(
4014
+ UIApplication.shared.applicationState == .background)
4015
+ let center = NotificationCenter.default
4016
+ appLifecycleObservers.append(
4017
+ center.addObserver(
4018
+ forName: UIApplication.didEnterBackgroundNotification,
4019
+ object: nil, queue: .main
4020
+ ) { [weak self] _ in
4021
+ self?.progressEmissionGate.setBackgrounded(true)
4022
+ })
4023
+ appLifecycleObservers.append(
4024
+ center.addObserver(
4025
+ forName: UIApplication.willEnterForegroundNotification,
4026
+ object: nil, queue: .main
4027
+ ) { [weak self] _ in
4028
+ self?.progressEmissionGate.setBackgrounded(false)
4029
+ })
4030
+ }
4031
+
4032
+ /// Remove the app-lifecycle observers registered by
4033
+ /// `installAppLifecycleObservers`. Idempotent.
4034
+ private func removeAppLifecycleObservers() {
4035
+ let center = NotificationCenter.default
4036
+ appLifecycleObservers.forEach { center.removeObserver($0) }
4037
+ appLifecycleObservers.removeAll()
4038
+ }
4039
+
4040
+ /// Try to recover from a transient failure by rebuilding the
4041
+ /// AVQueuePlayer items at the current position. Called from
4042
+ /// `handleCurrentItemFailedToLoad` / `handlePlayerItemFailedToPlayToEndTime`
4043
+ /// (auto-retry) AND from `play()` (one-shot stuck-recovery).
4044
+ ///
4045
+ /// Implementation: `fullRebuildPlayerQueue` with the current index
4046
+ /// unchanged — it does `removeAllItems()` + reinserts fresh
4047
+ /// AVPlayerItems via `AVQueueBuilder.makeItem(...)` using the
4048
+ /// existing `queueItemIds` (so dedup keys remain stable). Position
4049
+ /// is reset to 0 (mid-play failures lose position; matches RNTP
4050
+ /// fork's `recreateAVPlayer` behaviour).
4051
+ internal func retryFailedItem(queueItemId qid: String) {
4052
+ guard let player = self.player else { return }
4053
+ // Verify the failed item is still the current one. A subsequent
4054
+ // user skip / queue mutation could have moved past it; in that
4055
+ // case the retry is moot.
4056
+ guard let idx = self.queueItemIds.firstIndex(of: qid),
4057
+ idx == self.currentTrackIndex else { return }
4058
+ self.performingMutation { self.fullRebuildPlayerQueue() }
4059
+ // Resume playback if not already playing. Through the engine so the
4060
+ // user's playback speed is restored (a raw AVPlayer.play() resets
4061
+ // rate to 1.0).
4062
+ if player.timeControlStatus != .playing {
4063
+ self.engine?.play()
4064
+ }
4065
+ }
4066
+
4067
+ /// Run `body` on the main thread synchronously and return its
4068
+ /// value. Short-circuits when already on main so consumers calling
4069
+ /// from main don't deadlock. Used by Nitro getters + listener
4070
+ /// register/dispose paths that aren't Promise-wrapped and run on
4071
+ /// whatever thread the JS bridge uses.
4072
+ private func onMainSync<T>(_ body: () -> T) -> T {
4073
+ if Thread.isMainThread { return body() }
4074
+ return DispatchQueue.main.sync(execute: body)
4075
+ }
4076
+
4077
+ // MARK: - Automotive / voice handler registration
4078
+
4079
+ /// Atomically replace the cached `BrowseSnapshot` on the
4080
+ /// process-wide [CarPlayCoordinator]. The data provider posts a
4081
+ /// `didUpdateNotification` on the main queue; an active CarPlay
4082
+ /// scene observes the notification + rebuilds the root template
4083
+ /// against the new snapshot. Push semantics — most-recent call
4084
+ /// wins. Safe to call before a CarPlay scene attaches: the
4085
+ /// snapshot is cached on the coordinator + the next scene reads
4086
+ /// it directly at didConnect.
4087
+ func setBrowseSnapshot(snapshot: BrowseSnapshot) throws {
4088
+ CarPlayCoordinator.shared.dataProvider.setSnapshot(snapshot)
4089
+ }
4090
+
4091
+ func onBrowseRequest(
4092
+ callback: @escaping (_ parentId: String) -> Promise<Promise<[BrowseItem]>>
4093
+ ) throws -> () -> Void {
4094
+ return CarPlayCoordinator.shared.bridge.registerOnBrowseRequest(callback)
4095
+ }
4096
+
4097
+ // `onSearchRequest` is Android-only — CarPlay audio-entitled apps
4098
+ // cannot legally present `CPSearchTemplate` (per the CarPlay App
4099
+ // Programming Guide template-by-category rules); pushing or
4100
+ // presenting it raises NSInvalidArgumentException at runtime. Apple
4101
+ // Music + Spotify both dropped CarPlay search in iOS 26; the
4102
+ // canonical CarPlay search affordance for audio apps is Siri via
4103
+ // SiriKit's `INPlayMediaIntent`, not an in-app search template.
4104
+ //
4105
+ // `onPlayFromSearchRequest` IS wired on iOS — it serves the
4106
+ // SiriKit `INPlayMediaIntent` path: Siri parses "Hey Siri play X
4107
+ // on MyApp" into a structured `INMediaSearch`, the lib's
4108
+ // `PlayMediaIntentHandler` maps it to `MediaSearchRequest`, and
4109
+ // dispatches into this callback via `CarPlayBridge`.
4110
+ //
4111
+ // For `onSearchRequest` we accept the consumer's registration on
4112
+ // iOS to keep the TS API shape cross-platform; the callback is
4113
+ // discarded (`_ = callback` consumes the value), and the returned
4114
+ // disposer is a no-op so the consumer's bookkeeping stays sane.
4115
+ func onSearchRequest(
4116
+ callback: @escaping (_ query: String) -> Promise<Promise<[BrowseItem]>>
4117
+ ) throws -> () -> Void {
4118
+ _ = callback
4119
+ return {}
4120
+ }
4121
+
4122
+ func onPlayFromSearchRequest(
4123
+ callback: @escaping (_ request: MediaSearchRequest) -> Promise<Promise<[TrackItem]>>
4124
+ ) throws -> () -> Void {
4125
+ return CarPlayCoordinator.shared.bridge.registerOnPlayFromSearchRequest(callback)
4126
+ }
4127
+
4128
+ func onPlayFromIdRequest(
4129
+ callback: @escaping (_ mediaId: String) -> Promise<Promise<[TrackItem]>>
4130
+ ) throws -> () -> Void {
4131
+ return CarPlayCoordinator.shared.bridge.registerOnPlayFromIdRequest(callback)
4132
+ }
4133
+
4134
+ // Donation Hybrid method — fire-and-forget into Siri's vocabulary
4135
+ // donation pipeline. Real impl lives in `ios/Siri/VoiceDonation.swift`.
4136
+ // Uses `INVocabulary.setVocabularyStrings(_:of:)` to seed Siri's NLU
4137
+ // with the consumer's artist + playlist names.
4138
+ func donateVoiceVocabulary(vocabulary: VoiceVocabulary) throws {
4139
+ VoiceDonation.donateVoiceVocabulary(vocabulary)
4140
+ }
4141
+
4142
+ func onCarConnect(callback: @escaping () -> Void) throws -> () -> Void {
4143
+ return CarPlayCoordinator.shared.bridge.registerOnCarConnect(callback)
4144
+ }
4145
+
4146
+ // `onServiceReady` is an Android-only signal. iOS apps die fully
4147
+ // under memory pressure (no separate service lifecycle), so there's
4148
+ // no `service-reborn` scenario to surface here. The lib accepts the
4149
+ // registration to keep the cross-platform API shape consistent and
4150
+ // returns a no-op disposer; the callback is consumed (so it isn't
4151
+ // retained) and never invoked. Consumers writing for both platforms
4152
+ // register one handler; on iOS only `onCarConnect` carries the
4153
+ // equivalent "automotive surface available" signal.
4154
+ func onServiceReady(
4155
+ callback: @escaping (_ reason: ServiceReadyReason) -> Void
4156
+ ) throws -> () -> Void {
4157
+ _ = callback
4158
+ return {}
4159
+ }
4160
+
4161
+ func onCarDisconnect(callback: @escaping () -> Void) throws -> () -> Void {
4162
+ return CarPlayCoordinator.shared.bridge.registerOnCarDisconnect(callback)
4163
+ }
4164
+
4165
+ // MARK: - Lookahead cache — runtime config + status surface
4166
+ func setLookaheadCache(config: LookaheadCacheConfig) throws -> Promise<Void> {
4167
+ return Promise<Void>.async {
4168
+ // Lift-throw across the non-throwing `onMain` closure: capture
4169
+ // any error inside, re-throw after the main-hop completes so JS
4170
+ // sees `[QP_EVICTION_UNSUPPORTED]` (or other validation
4171
+ // failures) as a Promise rejection rather than silent no-op.
4172
+ var caught: Error?
4173
+ await self.onMain {
4174
+ do {
4175
+ try self.applyLookaheadConfig(config)
4176
+ } catch {
4177
+ caught = error
4178
+ }
4179
+ }
4180
+ if let error = caught { throw error }
4181
+ }
4182
+ }
4183
+
4184
+ func getLookaheadCacheStatus() throws -> CacheStatus {
4185
+ // Synchronous read — must hop to main since the cache + prefetcher
4186
+ // were built there. `DispatchQueue.main.sync` deadlocks if called
4187
+ // from main; the Thread guard short-circuits that path.
4188
+ if Thread.isMainThread {
4189
+ return buildCacheStatusSnapshot()
4190
+ }
4191
+ return DispatchQueue.main.sync { self.buildCacheStatusSnapshot() }
4192
+ }
4193
+
4194
+ func clearLookaheadCache() throws -> Promise<Void> {
4195
+ return Promise<Void>.async {
4196
+ await self.onMain {
4197
+ // Cancel any in-flight prefetch + drop everything on disk.
4198
+ // We deliberately do NOT call rescheduleLookahead() here —
4199
+ // an immediate refill makes `clearLookaheadCache` impossible
4200
+ // to observe at zero from JS, and "clear" semantically means
4201
+ // "wipe and stop" (the consumer can re-trigger prefetch by
4202
+ // calling setLookaheadCache, skipToIndex, or setQueue again).
4203
+ self.lookaheadCachePrefetcher?.cancel()
4204
+ self.lookaheadCache?.clear()
4205
+ self.emitCacheStatus()
4206
+ }
4207
+ }
4208
+ }
4209
+
4210
+ func onCacheStatusChange(callback: @escaping (_ status: CacheStatus) -> Void) throws -> () -> Void {
4211
+ // ListenerRegistry.add is internally serialized, so a JS consumer
4212
+ // that does
4213
+ // const unsub = onCacheStatusChange(cb)
4214
+ // await setLookaheadCache(cfg)
4215
+ // is guaranteed to see the post-config-change emit (the
4216
+ // setLookaheadCache call synchronously crosses the bridge after
4217
+ // the listener is in the registry).
4218
+ let id = cacheStatusListeners.add(callback)
4219
+ return { [weak self] in self?.cacheStatusListeners.remove(id: id) }
4220
+ }
4221
+
4222
+ /// Returns the active item's runtime audio format, or the null variant
4223
+ /// when nothing is currently playable / the format hasn't loaded yet.
4224
+ /// `Variant_NullType_NowPlayingFormat` is Nitrogen's bridge for
4225
+ /// `NowPlayingFormat | null` (`.first(.null)` = absent, `.second(format)` =
4226
+ /// present). Cached by the async resolver; never blocks.
4227
+ ///
4228
+ /// An empty / out-of-range queue short-circuits to `.null` regardless
4229
+ /// of the cache — the canonical-state branch below.
4230
+ func getNowPlayingFormat() throws -> Variant_NullType_NowPlayingFormat {
4231
+ return onMainSync {
4232
+ // Lib canonical state takes precedence over the cached format.
4233
+ // After `clearQueue` / queue-end the engine's `currentMediaItem`
4234
+ // can briefly retain the last item; without this guard the read
4235
+ // returns the prior track's stale codec / bitrate / duration
4236
+ // even though `currentTrackIndex == -1` and the JS-facing state
4237
+ // shows track == null.
4238
+ if self.tracks.isEmpty
4239
+ || self.currentTrackIndex < 0
4240
+ || self.currentTrackIndex >= self.tracks.count {
4241
+ return .first(.null)
4242
+ }
4243
+ // RG fields are read live from `AudioTapProvider`'s per-item
4244
+ // cache so they surface as soon as the one-shot
4245
+ // `MetadataReader` extract completes (sub-100 ms post queue
4246
+ // load) — independently of the slow-path AVFoundation
4247
+ // metadata/format-description loads that populate
4248
+ // `lastEmittedFormat`'s codec/bitrate/duration fields.
4249
+ let activeItem = self.activeMediaItem
4250
+ let rgData = activeItem.flatMap { AudioTapProvider.shared.replayGainDataForItem($0) }
4251
+ let rgActive = activeItem.map { AudioTapProvider.shared.replayGainActiveForItem($0) } ?? false
4252
+ if let cached = self.lastEmittedFormat {
4253
+ return .second(mergeRG(cached, rgData: rgData, rgActive: rgActive))
4254
+ }
4255
+ // No slow-path snapshot yet. Surface RG fields alone so
4256
+ // consumers waiting on RG (e.g. visualisation, normalisation
4257
+ // UI) don't have to wait for the AVAsset chain to complete.
4258
+ // Non-RG fields fall through to their `.null` sentinels.
4259
+ if rgData != nil {
4260
+ return .second(NowPlayingFormat(
4261
+ codec: .unknown,
4262
+ container: .unknown,
4263
+ mimeType: .first(.null),
4264
+ sampleRate: .first(.null),
4265
+ channelCount: .first(.null),
4266
+ bitrate: .first(.null),
4267
+ durationSec: .first(.null),
4268
+ replayGainTrackDb: rgData?.trackGainDb.map { .second($0) } ?? .first(.null),
4269
+ replayGainTrackPeak: rgData?.trackPeak.map { .second($0) } ?? .first(.null),
4270
+ replayGainAlbumDb: rgData?.albumGainDb.map { .second($0) } ?? .first(.null),
4271
+ replayGainAlbumPeak: rgData?.albumPeak.map { .second($0) } ?? .first(.null),
4272
+ replayGainActive: rgActive,
4273
+ ios: NowPlayingFormatIosExtras(bitsPerChannel: .first(.null)),
4274
+ android: nil
4275
+ ))
4276
+ }
4277
+ return .first(.null)
4278
+ }
4279
+ }
4280
+
4281
+ /// Replace the four RG fields of `cached` with live values from
4282
+ /// the AudioTap. Non-RG fields are preserved as-is.
4283
+ private func mergeRG(
4284
+ _ cached: NowPlayingFormat,
4285
+ rgData: ReplayGainData?,
4286
+ rgActive: Bool
4287
+ ) -> NowPlayingFormat {
4288
+ return NowPlayingFormat(
4289
+ codec: cached.codec,
4290
+ container: cached.container,
4291
+ mimeType: cached.mimeType,
4292
+ sampleRate: cached.sampleRate,
4293
+ channelCount: cached.channelCount,
4294
+ bitrate: cached.bitrate,
4295
+ durationSec: cached.durationSec,
4296
+ replayGainTrackDb: rgData?.trackGainDb.map { .second($0) } ?? .first(.null),
4297
+ replayGainTrackPeak: rgData?.trackPeak.map { .second($0) } ?? .first(.null),
4298
+ replayGainAlbumDb: rgData?.albumGainDb.map { .second($0) } ?? .first(.null),
4299
+ replayGainAlbumPeak: rgData?.albumPeak.map { .second($0) } ?? .first(.null),
4300
+ replayGainActive: rgActive,
4301
+ ios: cached.ios,
4302
+ android: cached.android
4303
+ )
4304
+ }
4305
+
4306
+ /// Emit the current now-playing format to subscribers. Used by
4307
+ /// the AudioTap RG-data-populated callback to push the updated
4308
+ /// format without re-running `NowPlayingFormatExtractor`.
4309
+ internal func emitCurrentNowPlayingFormat(for item: AVPlayerItem) {
4310
+ guard let active = self.activeMediaItem,
4311
+ ObjectIdentifier(active) == ObjectIdentifier(item) else { return }
4312
+ let rgData = AudioTapProvider.shared.replayGainDataForItem(item)
4313
+ let rgActive = AudioTapProvider.shared.replayGainActiveForItem(item)
4314
+ if let cached = self.lastEmittedFormat {
4315
+ let merged = mergeRG(cached, rgData: rgData, rgActive: rgActive)
4316
+ self.lastEmittedFormat = merged
4317
+ self.nowPlayingFormatListeners.forEach { $0(.second(merged)) }
4318
+ }
4319
+ }
4320
+
4321
+ func onNowPlayingFormatChange(
4322
+ callback: @escaping (_ format: Variant_NullType_NowPlayingFormat?) -> Void
4323
+ ) throws -> () -> Void {
4324
+ let id = nowPlayingFormatListeners.add(callback)
4325
+ return { [weak self] in self?.nowPlayingFormatListeners.remove(id: id) }
4326
+ }
4327
+
4328
+ /// Bump the request generation, drop a stale cache (emitting null so
4329
+ /// the JS consumer sees a loading state), and kick off the async
4330
+ /// extractor for the current AVPlayerItem. Call sites: every
4331
+ /// `\.currentItem` KVO transition + every `.readyToPlay` status flip
4332
+ /// for the active item. When there is no current item, clears + emits
4333
+ /// null and returns without launching a Task.
4334
+ /// Engine-agnostic active-item accessor. Reads via the active
4335
+ /// engine's `currentMediaItem` getter (works for GaplessEngine
4336
+ /// AND CrossfadeEngine — `self.player` is nil during crossfade-
4337
+ /// mode playback because that engine routes through dual private
4338
+ /// AVPlayers). Falls back to `self.player?.currentItem` for paths
4339
+ /// that pre-date the engine surface.
4340
+ internal var activeMediaItem: AVPlayerItem? {
4341
+ return self.engine?.currentMediaItem ?? self.player?.currentItem
4342
+ }
4343
+
4344
+ internal func refreshNowPlayingFormatForActiveItem(force: Bool = false) {
4345
+ self.nowPlayingFormatRequestGen &+= 1
4346
+ let gen = self.nowPlayingFormatRequestGen
4347
+
4348
+ guard let item = self.activeMediaItem else {
4349
+ if self.lastEmittedFormat != nil || self.lastEmittedFormatItemId != nil {
4350
+ self.lastEmittedFormat = nil
4351
+ self.lastEmittedFormatItemId = nil
4352
+ nowPlayingFormatListeners.forEach { $0(.first(.null)) }
4353
+ }
4354
+ return
4355
+ }
4356
+ let itemId = ObjectIdentifier(item)
4357
+
4358
+ // Same item already resolved — no work, no emit. `force: true`
4359
+ // bypasses this dedup; used by the AudioTapProvider RG-data-
4360
+ // populated callback to refresh the snapshot once the async RG
4361
+ // extract lands data that was unavailable on the initial
4362
+ // resolve.
4363
+ if !force, self.lastEmittedFormatItemId == itemId, self.lastEmittedFormat != nil {
4364
+ return
4365
+ }
4366
+
4367
+ // New (or different) item — drop stale cache + emit null so the
4368
+ // JS consumer can show a loading state before the new format
4369
+ // lands. Skip the null-emit when `force: true` and we're
4370
+ // re-resolving the same item; a transient null in the middle of a
4371
+ // stable item would needlessly flash a loading state.
4372
+ if !force, self.lastEmittedFormat != nil {
4373
+ self.lastEmittedFormat = nil
4374
+ self.lastEmittedFormatItemId = nil
4375
+ nowPlayingFormatListeners.forEach { $0(.first(.null)) }
4376
+ }
4377
+
4378
+ Task { [weak self] in
4379
+ let resolved = await NowPlayingFormatExtractor.extract(item: item)
4380
+ await MainActor.run { [weak self] in
4381
+ guard let self else { return }
4382
+ guard self.nowPlayingFormatRequestGen == gen else { return }
4383
+ // Drops fires from items the active engine has already
4384
+ // advanced past during the await.
4385
+ guard let current = self.activeMediaItem,
4386
+ ObjectIdentifier(current) == itemId else { return }
4387
+ guard let format = resolved else { return }
4388
+ self.lastEmittedFormat = format
4389
+ self.lastEmittedFormatItemId = itemId
4390
+ self.nowPlayingFormatListeners.forEach { $0(.second(format)) }
4391
+ }
4392
+ }
4393
+ }
4394
+
4395
+ /// Compare two `Variant_NullType_Double?` values for "is the bitrate
4396
+ /// effectively the same". Treats `nil` and `.first(.null)` as
4397
+ /// equivalent (both mean "no bitrate"); `.second(Double)` matches
4398
+ /// only on IEEE-754 equality (Swift's `Double ==`). Value equality is
4399
+ /// intentional — the extractor produces values from a small set of
4400
+ /// integer-source feeds (Core Audio bitrate is `UInt32`, access-log
4401
+ /// bitrate is a server-declared kbps figure) so equality holds across
4402
+ /// repeated reads of the same underlying signal.
4403
+ private func sameBitrateVariant(
4404
+ _ a: Variant_NullType_Double?, _ b: Variant_NullType_Double?
4405
+ ) -> Bool {
4406
+ let aValue: Double? = {
4407
+ guard let a = a, case .second(let v) = a else { return nil }
4408
+ return v
4409
+ }()
4410
+ let bValue: Double? = {
4411
+ guard let b = b, case .second(let v) = b else { return nil }
4412
+ return v
4413
+ }()
4414
+ return aValue == bValue
4415
+ }
4416
+
4417
+ /// Re-resolve the active item's now-playing format for the streaming-MP3
4418
+ /// bitrate-fill path. The base `refreshNowPlayingFormatForActiveItem`
4419
+ /// dedups on `(itemId, lastEmittedFormat != nil)` so re-running it for
4420
+ /// the same item is a no-op; this entry point bypasses that dedup,
4421
+ /// runs the extractor, and emits only when the resolved bitrate
4422
+ /// actually differs from the cached value. Called from the access-log
4423
+ /// notification handler when the active item is streaming MP3 with no
4424
+ /// bitrate yet.
4425
+ ///
4426
+ /// `bitrateReresolveInFlight` early-returns when a previous call is
4427
+ /// still resolving — access-log notifications fire many times per
4428
+ /// second so without this gate every fire while the first extract is
4429
+ /// running would spawn another detached Task. The active-item gate +
4430
+ /// generation token + `lastEmittedFormatItemId` check still apply on
4431
+ /// the completion side; the flag just suppresses redundant launches.
4432
+ internal func reresolveNowPlayingFormatForBitrate() {
4433
+ if self.bitrateReresolveInFlight { return }
4434
+ guard let item = self.activeMediaItem else { return }
4435
+ self.nowPlayingFormatRequestGen &+= 1
4436
+ let gen = self.nowPlayingFormatRequestGen
4437
+ let itemId = ObjectIdentifier(item)
4438
+ self.bitrateReresolveInFlight = true
4439
+
4440
+ Task { [weak self] in
4441
+ let resolved = await NowPlayingFormatExtractor.extract(item: item)
4442
+ await MainActor.run { [weak self] in
4443
+ guard let self else { return }
4444
+ defer { self.bitrateReresolveInFlight = false }
4445
+ guard self.nowPlayingFormatRequestGen == gen else { return }
4446
+ guard let current = self.activeMediaItem,
4447
+ ObjectIdentifier(current) == itemId else { return }
4448
+ guard self.lastEmittedFormatItemId == itemId else { return }
4449
+ guard let format = resolved else { return }
4450
+ // Emit only on a meaningful change. Bitrate is the field this
4451
+ // path exists to fill; comparing the variant tag handles both
4452
+ // null→value transitions and value→value updates without
4453
+ // pulling the wrapped Double into Equatable territory.
4454
+ let prev = self.lastEmittedFormat
4455
+ let bitrateChanged = !sameBitrateVariant(prev?.bitrate, format.bitrate)
4456
+ if !bitrateChanged { return }
4457
+ self.lastEmittedFormat = format
4458
+ self.lastEmittedFormatItemId = itemId
4459
+ self.nowPlayingFormatListeners.forEach { $0(.second(format)) }
4460
+ }
4461
+ }
4462
+ }
4463
+
4464
+ /// Apply a runtime config update on main. Both runtime dimensions are
4465
+ /// live (no configure() needed):
4466
+ /// - `enabled` toggle: build/teardown the prefetcher against the cache
4467
+ /// - `lookaheadCount` change: live update on the prefetcher
4468
+ ///
4469
+ /// Cache SIZE + eviction policy are NOT runtime dimensions — they are fixed
4470
+ /// at `configure()` from `PlayerConfig` (kept consistent with Android, whose
4471
+ /// SimpleCache is immutable after construction).
4472
+ internal func applyLookaheadConfig(_ config: LookaheadCacheConfig) throws {
4473
+ let previous = self.lookaheadConfig
4474
+ self.lookaheadConfig = config
4475
+
4476
+ let enableChanged = config.enabled != previous.enabled
4477
+ let countChanged = config.lookaheadCount != previous.lookaheadCount
4478
+
4479
+ if enableChanged {
4480
+ rebuildCacheStack(config: config)
4481
+ } else if config.enabled {
4482
+ applyLiveConfigUpdates(config: config, countChanged: countChanged)
4483
+ }
4484
+
4485
+ emitCacheStatus()
4486
+ }
4487
+
4488
+ /// Configure-time cache disk budget in MB — `PlayerConfig.lookaheadCacheMaxSizeMb`
4489
+ /// or the 100 MB default. Fixed at cache construction; reported read-only via
4490
+ /// `getLookaheadCacheStatus().maxSizeMb`.
4491
+ private func configuredCacheMaxSizeMb() -> Double {
4492
+ self.config.lookaheadCacheMaxSizeMb
4493
+ ?? Double(LookaheadCache.DEFAULT_MAX_SIZE_BYTES) / Double(LookaheadCache.BYTES_PER_MB)
4494
+ }
4495
+
4496
+ /// Configure-time cache eviction policy — `PlayerConfig.lookaheadCacheEvictionPolicy`
4497
+ /// or `.lru`. Fixed at cache construction; a change at `configure()` clears the
4498
+ /// cache and rebuilds under the new policy (see `configure`).
4499
+ private func configuredEvictionPolicy() -> EvictionPolicy {
4500
+ self.config.lookaheadCacheEvictionPolicy ?? .lru
4501
+ }
4502
+
4503
+ /// Quiesce or re-arm the cache + prefetcher pair when the
4504
+ /// `enabled` flag flipped. Disable cancels in-flight prefetch +
4505
+ /// quiesces the cache (URLSession STAYS alive — it is only
4506
+ /// invalidated by full `destroy`). Enable rebuilds the prefetcher
4507
+ /// (or constructs the pair from scratch on first enable) and
4508
+ /// reschedules.
4509
+ ///
4510
+ /// Keeping the cache instance + URLSession alive across disable
4511
+ /// avoids `Task created in a session that has been invalidated`
4512
+ /// (Obj-C exception, uncatchable in Swift's `do-catch`) when a
4513
+ /// prefetcher Task survived disable + tried to download against
4514
+ /// a freshly-invalidated session before the dispatch barrier saw
4515
+ /// the cancellation.
4516
+ private func rebuildCacheStack(config: LookaheadCacheConfig) {
4517
+ if !config.enabled {
4518
+ lookaheadCachePrefetcher?.tearDown()
4519
+ lookaheadCachePrefetcher = nil
4520
+ lookaheadCache?.disable()
4521
+ return
4522
+ }
4523
+ if lookaheadCache == nil {
4524
+ // Size is fixed at construction from the configure-time budget.
4525
+ lookaheadCache = LookaheadCache(
4526
+ maxSizeBytes: LookaheadCache.maxBytes(forMb: configuredCacheMaxSizeMb()),
4527
+ evictionPolicy: configuredEvictionPolicy()
4528
+ )
4529
+ } else {
4530
+ lookaheadCache?.enable()
4531
+ }
4532
+ guard let cache = lookaheadCache else { return }
4533
+ let prefetcher = LookaheadCachePrefetcher(
4534
+ cache: cache,
4535
+ configHeaders: self.config.httpHeaders,
4536
+ configUserAgent: self.config.userAgent
4537
+ )
4538
+ prefetcher.defaultLookaheadCount = max(0, Int(config.lookaheadCount))
4539
+ prefetcher.onTrackProcessed = { [weak self] in self?.emitCacheStatus() }
4540
+ lookaheadCachePrefetcher = prefetcher
4541
+ rescheduleLookahead()
4542
+ }
4543
+
4544
+ /// Apply in-place config updates when the cache stack already exists
4545
+ /// (no enable-flag change). Only `lookaheadCount` is a runtime dimension
4546
+ /// now — it updates the prefetcher's default + reschedules. Cache size is
4547
+ /// configure-time and never changes here.
4548
+ private func applyLiveConfigUpdates(
4549
+ config: LookaheadCacheConfig, countChanged: Bool
4550
+ ) {
4551
+ if countChanged {
4552
+ lookaheadCachePrefetcher?.defaultLookaheadCount =
4553
+ max(0, Int(config.lookaheadCount))
4554
+ rescheduleLookahead()
4555
+ }
4556
+ }
4557
+
4558
+ /// Build a [CacheStatus] snapshot from current cache + prefetcher
4559
+ /// state. Synchronous — caller must already be on main.
4560
+ internal func buildCacheStatusSnapshot() -> CacheStatus {
4561
+ let cache = self.lookaheadCache
4562
+ let currentBytes = cache?.currentSizeBytes() ?? 0
4563
+ // Queue-agnostic: count fully-cached entries in the cache itself,
4564
+ // not the intersection with `self.tracks`. Mirrors the semantics
4565
+ // of `currentSizeBytes` (total cache disk usage) and the
4566
+ // prefetcher's `currentlyDownloadingUrl` (active downloads) — all
4567
+ // three reflect cache state independent of any current queue.
4568
+ // Per-track local/stream/cache attribution for the active queue
4569
+ // is exposed via `getCurrentTrackSource()`.
4570
+ let fullyCached = cache?.fullyCachedCount() ?? 0
4571
+ let inFlight = lookaheadCachePrefetcher?.currentlyDownloadingUrl
4572
+ return CacheStatus(
4573
+ enabled: lookaheadConfig.enabled,
4574
+ currentSizeMb: Double(currentBytes) / Double(LookaheadCache.BYTES_PER_MB),
4575
+ maxSizeMb: configuredCacheMaxSizeMb(),
4576
+ tracksFullyCached: Double(fullyCached),
4577
+ currentlyCaching: inFlight.map { [$0] } ?? []
4578
+ )
4579
+ }
4580
+
4581
+ /// Fire the cache-status callback with a fresh snapshot. Caller
4582
+ /// must already be on main.
4583
+ internal func emitCacheStatus() {
4584
+ guard !cacheStatusListeners.isEmpty else { return }
4585
+ let snapshot = buildCacheStatusSnapshot()
4586
+ cacheStatusListeners.forEach { $0(snapshot) }
4587
+ }
4588
+ }
4589
+
4590
+ // MARK: - PlaybackEngineDelegate
4591
+
4592
+ /// `CrossfadeEngine` fires these callbacks; `GaplessEngine`'s
4593
+ /// equivalent surface continues to flow through `self.player`'s
4594
+ /// KVO chain installed by `installGaplessObservers` /
4595
+ /// `installEventObservers`. Each delegate method therefore runs
4596
+ /// only when the active engine is `CrossfadeEngine` (per the
4597
+ /// `delegate = self` wiring in `applyEngineSwapForMode`).
4598
+ extension TrackPlayer: PlaybackEngineDelegate {
4599
+ func engineStateMaybeChanged(
4600
+ _ engine: PlaybackEngine, reason: StateChangeReason?
4601
+ ) {
4602
+ if let reason {
4603
+ // Engine-supplied classified reason takes precedence over
4604
+ // any prior staked reason — match the gapless KVO path
4605
+ // where pendingStateChangeReason is overwritten by the
4606
+ // newer staked reason.
4607
+ self.pendingStateChangeReason = reason
4608
+ }
4609
+ // The leading leg's `timeControlStatus` drives `.buffering` (load /
4610
+ // rebuffer) and the end-of-queue latch drives `.ended`.
4611
+ let computed = PlayerStateDerivation.translate(
4612
+ status: engine.timeControlStatus,
4613
+ currentIndex: self.currentTrackIndex,
4614
+ hasCurrentItem: engine.currentMediaItem != nil,
4615
+ reachedQueueEnd: self.reachedQueueEnd
4616
+ )
4617
+ emitState(computed, reason: reasonForComputedState(computed))
4618
+ }
4619
+
4620
+ func engineIsPlayingChanged(_ engine: PlaybackEngine, isPlaying: Bool) {
4621
+ // No-op — engineStateMaybeChanged already drives the
4622
+ // PlayerState emit on every leg-state change; this method is
4623
+ // here for delegate parity with the protocol so future
4624
+ // diagnostics / progress wake-up hooks have a slot.
4625
+ _ = engine
4626
+ _ = isPlaying
4627
+ }
4628
+
4629
+ func engineDidTransitionTrack(_ engine: PlaybackEngine) {
4630
+ // Bring TrackPlayer's currentTrackIndex into sync with the
4631
+ // engine's index — CrossfadeEngine.completeCrossfade
4632
+ // increments engine.currentMediaItemIndex; on setItems / seek
4633
+ // the engine's index is already aligned with the caller's
4634
+ // resumeIndex.
4635
+ //
4636
+ // Crossfade post-swap echo: fade-start already fired
4637
+ // `engineCrossfadeDidBegin` which dispatched the JS-facing
4638
+ // track-change for the same incoming index. The
4639
+ // `lastEmittedQueueItemId` dedup inside `dispatchTrackChange`
4640
+ // turns the second call into a no-op. Capability + lookahead
4641
+ // refresh still run unconditionally — both are idempotent and
4642
+ // the second pass picks up any state that landed during the
4643
+ // fade window.
4644
+ // Skip the engine→canonical index sync during a queue mutation: the
4645
+ // mutation already computed the authoritative currentTrackIndex via
4646
+ // QueueMutationArithmetic, and the rebuild's setItems(startIndex: 0) on the
4647
+ // `tracks[cur...]` slice would otherwise clobber it with the slice's
4648
+ // engine-relative index 0. (CrossfadeEngine.setItems fires this delegate
4649
+ // synchronously; GaplessEngine does not, so this only bit crossfade.)
4650
+ let engineIdx = engine.currentMediaItemIndex
4651
+ if !self.isMutatingPlayerQueue, engineIdx >= 0, engineIdx < self.tracks.count {
4652
+ self.currentTrackIndex = engineIdx
4653
+ }
4654
+ let reason = self.pendingTrackChangeReason ?? .autoAdvance
4655
+ self.pendingTrackChangeReason = nil
4656
+ dispatchTrackChange(reason: reason)
4657
+ self.recomputeCapabilities()
4658
+ self.rescheduleLookahead()
4659
+ // Refresh the now-playing format for the new active item. The crossfade
4660
+ // engine's `\.tracks` KVO is `.new`-only and a forward skip rebuilds the
4661
+ // incoming leg as a fresh AVPlayerItem whose tracks key isn't auto-loaded
4662
+ // (only playable/duration are), so engineActiveItemFormatChanged may never
4663
+ // fire for it — without this, getNowPlayingFormat() stays nil after a
4664
+ // skip to a cached track. The extractor loads the format asynchronously
4665
+ // and the lastEmittedFormatItemId dedup makes a redundant call a no-op.
4666
+ self.refreshNowPlayingFormatForActiveItem()
4667
+ }
4668
+
4669
+ func engineCrossfadeDidBegin(_ engine: PlaybackEngine, incomingIndex: Int) {
4670
+ // Fade-start: the standby leg is now audibly playing (volume 0
4671
+ // ramping up). Flip JS-facing active track + lock-screen / Now
4672
+ // Playing surface to the incoming track immediately so the
4673
+ // user sees the new track's title/artwork the moment they
4674
+ // start hearing it. The leading leg continues fading out for
4675
+ // the configured `crossfadeDurationMs`; engine accessors
4676
+ // already prefer the standby leg while `fadeInFlight` is true.
4677
+ //
4678
+ // Reason is hardcoded `.autoAdvance` — fade-start is always an
4679
+ // auto-advance event; any user-driven transition (skipToNext /
4680
+ // skipToIndex / setMediaItems) would have called
4681
+ // `cancelFadeInternal` first, so a stashed
4682
+ // `pendingTrackChangeReason` here is stale and should not
4683
+ // re-attribute the fade.
4684
+ _ = engine
4685
+ guard incomingIndex >= 0, incomingIndex < self.tracks.count else { return }
4686
+ self.currentTrackIndex = incomingIndex
4687
+ self.pendingTrackChangeReason = nil
4688
+ dispatchTrackChange(reason: .autoAdvance)
4689
+ self.recomputeCapabilities()
4690
+ self.rescheduleLookahead()
4691
+ }
4692
+
4693
+ func engineCrossfadeDidCancel(_ engine: PlaybackEngine, revertedIndex: Int) {
4694
+ // Fade-cancelled mid-flight (pause / stop / focus loss /
4695
+ // queue mutation). JS already received `onTrackChange(incoming,
4696
+ // reason: .autoAdvance)` from the prior fade-start fire; the
4697
+ // incoming track is no longer the active one. Revert
4698
+ // `currentTrackIndex` to the leading-leg index and emit
4699
+ // `onTrackChange(leading, reason: .crossfadeCancelled)` so
4700
+ // consumer UI can rewind to the leading track.
4701
+ _ = engine
4702
+ guard revertedIndex >= 0, revertedIndex < self.tracks.count else { return }
4703
+ self.currentTrackIndex = revertedIndex
4704
+ self.pendingTrackChangeReason = nil
4705
+ dispatchTrackChange(reason: .crossfadeCancelled)
4706
+ self.recomputeCapabilities()
4707
+ self.rescheduleLookahead()
4708
+ }
4709
+
4710
+ func engineActiveItemFormatChanged(_ engine: PlaybackEngine) {
4711
+ _ = engine
4712
+ self.refreshNowPlayingFormatForActiveItem()
4713
+ // Streaming-item audioMix catch-up for the crossfade engine
4714
+ // (which fires this delegate hook on its own KVO chain). The
4715
+ // gapless engine has a parallel catch-up path through
4716
+ // `dispatchItemStatus(.readyToPlay)`; both call into
4717
+ // `refreshActiveItemMixes`, which is idempotent on items that
4718
+ // already carry a tap, so a double-fire is harmless.
4719
+ AudioTapProvider.shared.refreshActiveItemMixes()
4720
+ }
4721
+
4722
+ func engineDidFailWithError(_ engine: PlaybackEngine, error: Error) {
4723
+ _ = engine
4724
+ let underlying = error as NSError
4725
+ dispatchErrorOrRetry(
4726
+ item: nil,
4727
+ underlying: underlying,
4728
+ nativeDomainOverride: nil
4729
+ )
4730
+ }
4731
+
4732
+ func engineDidPlayItemToEnd(_ engine: PlaybackEngine) {
4733
+ // CrossfadeEngine fires this on natural end-of-leading; the
4734
+ // boundary signal that drives JS-facing track-change is
4735
+ // engineDidTransitionTrack which fires immediately after.
4736
+ //
4737
+ // Under repeat-one the same track replays with no track-change (the
4738
+ // queueItemId is unchanged, so dispatchTrackChange's dedup skips its
4739
+ // reset). Each loop is a fresh playthrough, so re-arm the milestone
4740
+ // tracker here for that case only.
4741
+ if self.repeatModeState == .track {
4742
+ self.milestoneTracker.reset()
4743
+ }
4744
+ _ = engine
4745
+ }
4746
+
4747
+ func enginePlaybackEnded(_ engine: PlaybackEngine) {
4748
+ _ = engine
4749
+ if self.repeatModeState == .queue {
4750
+ self.currentTrackIndex = 0
4751
+ self.pendingTrackChangeReason = .autoAdvance
4752
+ self.lastEmittedQueueItemId = nil
4753
+ self.performingMutation { self.fullRebuildPlayerQueue() }
4754
+ // The natural end stopped the player; the repeat-queue wrap must resume
4755
+ // playback from the rebuilt track 0. Gapless's AVQueuePlayer kept its
4756
+ // rate across the rebuild, but the crossfade rebuild's
4757
+ // replaceCurrentItem resets the leading leg's rate to 0 — without this
4758
+ // the queue wraps but sits paused.
4759
+ self.engine?.play()
4760
+ } else {
4761
+ self.reachedQueueEnd = true
4762
+ queueEndListeners.forEach { $0() }
4763
+ emitState(.ended, reason: .queueEnd)
4764
+ }
4765
+ }
4766
+ }