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,4242 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.content.ComponentName
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.ServiceConnection
7
+ import android.os.Handler
8
+ import android.os.IBinder
9
+ import android.os.Looper
10
+ import android.os.SystemClock
11
+ import android.util.Log
12
+ import androidx.annotation.MainThread
13
+ import androidx.annotation.VisibleForTesting
14
+ import androidx.lifecycle.DefaultLifecycleObserver
15
+ import androidx.lifecycle.LifecycleOwner
16
+ import androidx.lifecycle.ProcessLifecycleOwner
17
+ import androidx.media3.common.C
18
+ import androidx.media3.common.MediaItem
19
+ import androidx.media3.common.PlaybackException
20
+ import androidx.media3.common.Player
21
+ import androidx.media3.common.util.UnstableApi
22
+ import com.facebook.react.ReactApplication
23
+ import androidx.media3.exoplayer.ExoPlayer
24
+ import com.facebook.proguard.annotations.DoNotStrip
25
+ import com.margelo.nitro.NitroModules
26
+ import com.margelo.nitro.core.Promise
27
+ import java.util.concurrent.CountDownLatch
28
+ import kotlinx.coroutines.CompletableDeferred
29
+ import kotlinx.coroutines.sync.Mutex
30
+ import kotlinx.coroutines.sync.withLock
31
+ import kotlinx.coroutines.withTimeoutOrNull
32
+ import kotlin.coroutines.resume
33
+ import kotlin.coroutines.resumeWithException
34
+ import kotlin.coroutines.suspendCoroutine
35
+
36
+ /**
37
+ * Android TrackPlayer — Nitro HybridObject holding an ExoPlayer
38
+ * instance + runtime config. Mirrors `ios/TrackPlayer.swift`.
39
+ * Lifecycle, transport, queue mutation, skip, gapless, events, and
40
+ * audio focus all live here.
41
+ */
42
+ @DoNotStrip
43
+ @UnstableApi
44
+ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
45
+
46
+ // --- Stored state
47
+ //
48
+ // Mirrors iOS TrackPlayer.swift's "Stored state" block. Field
49
+ // visibility is `internal` + `@VisibleForTesting` so Robolectric
50
+ // tests can observe lifecycle transitions without going through
51
+ // the JNI-backed Nitro Promise machinery (which does not load in
52
+ // the JVM unit-test runtime).
53
+
54
+ /**
55
+ * `true` after [configureInternal] has bound + configured the
56
+ * service-side engine; `false` before configure or after destroy.
57
+ * Gates the [player] accessor so test scaffolding that pre-binds
58
+ * the service (without calling configure) sees `player == null`
59
+ * — preserving the lib-visible contract that transport calls
60
+ * are no-ops before configure.
61
+ */
62
+ private var configured: Boolean = false
63
+
64
+ /**
65
+ * The underlying ExoPlayer the lib's transport calls operate on.
66
+ * `null` before configure() / after destroy(). Computed every
67
+ * read from `serviceBinder?.engine?.mediaSessionPlayer` so that
68
+ * CrossfadeEngine internal role-swaps (which flip
69
+ * `leadingPlayer` between `playerA` and `playerB` at every fade
70
+ * boundary) do NOT leave a stale ExoPlayer reference cached on
71
+ * `TrackPlayer`. A cached field would silently route subsequent
72
+ * `prepare` / `clearMediaItems` calls onto the now-standby leg,
73
+ * desyncing queue state. (Wake-mode is routed through the engine,
74
+ * which applies it to both legs, so it is immune to this.)
75
+ *
76
+ * ExoPlayer is single-threaded — all access must happen on the
77
+ * main (Looper) thread, per Media3 docs.
78
+ */
79
+ @VisibleForTesting
80
+ internal val player: ExoPlayer?
81
+ get() = if (configured) serviceBinder?.engine?.mediaSessionPlayer else null
82
+
83
+ /**
84
+ * Lookahead cache built alongside the ExoPlayer in
85
+ * [configureInternal] and released in [destroyInternal]. Wraps the
86
+ * HTTP data-source factory so remote reads transparently populate
87
+ * an on-disk LRU. Built with [LookaheadCache.DEFAULT_MAX_SIZE_BYTES]
88
+ * by default; `setLookaheadCache` lets consumers disable / resize.
89
+ *
90
+ * Read on main only (configure / destroy run there); no @Volatile
91
+ * needed.
92
+ */
93
+ @VisibleForTesting
94
+ internal var lookaheadCache: LookaheadCache? = null
95
+ private set
96
+
97
+ /**
98
+ * Proactive prefetcher paired with [lookaheadCache].
99
+ * Reschedules sequential CacheWriter downloads for the next N
100
+ * remote tracks past `currentTrackIndex` after every queue + skip
101
+ * mutation. Default lookahead window = [LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT]
102
+ * (3); [setLookaheadCache] runtime config can override.
103
+ */
104
+ @VisibleForTesting
105
+ internal var lookaheadCacheWriter: LookaheadCacheWriter? = null
106
+ private set
107
+
108
+ /**
109
+ * Live lookahead cache config — initialised from
110
+ * [LookaheadCacheConfigDefaults] on first configure, mutated by
111
+ * [setLookaheadCache]. Read on main only (config + status
112
+ * methods always hop). Persists across destroy → configure so a
113
+ * consumer who set custom values once doesn't lose them on a
114
+ * reconfigure cycle.
115
+ */
116
+ @VisibleForTesting
117
+ internal var lookaheadConfig: LookaheadCacheConfig = LookaheadCacheConfig(
118
+ enabled = true,
119
+ lookaheadCount = LookaheadCacheWriter.DEFAULT_LOOKAHEAD_COUNT.toDouble()
120
+ )
121
+
122
+ /** Configure-time cache disk budget in MB — `PlayerConfig.lookaheadCacheMaxSizeMb`
123
+ * or the 100 MB default. The size is fixed at cache construction, so this is
124
+ * read only at configure() and reported read-only via getLookaheadCacheStatus. */
125
+ private fun cacheMaxSizeMbFrom(cfg: PlayerConfig): Double =
126
+ cfg.lookaheadCacheMaxSizeMb
127
+ ?: (LookaheadCache.DEFAULT_MAX_SIZE_BYTES / LookaheadCache.BYTES_PER_MB).toDouble()
128
+
129
+ /** Configure-time cache eviction policy — `PlayerConfig.lookaheadCacheEvictionPolicy`
130
+ * or LRU. Fixed at SimpleCache construction; a change clears + rebuilds the cache
131
+ * (see [configureInternal]). */
132
+ private fun evictionPolicyFrom(cfg: PlayerConfig): EvictionPolicy =
133
+ cfg.lookaheadCacheEvictionPolicy ?: EvictionPolicy.LRU
134
+
135
+ /**
136
+ * Cache-status listeners registered via [onCacheStatusChange].
137
+ * Multi-listener registry — every JS consumer that subscribes gets
138
+ * its own slot, disposed via the returned unsubscribe closure.
139
+ * Fired on download completion, eviction, config change, and clear.
140
+ */
141
+ private val cacheStatusListeners = ListenerRegistry<(CacheStatus) -> Unit>()
142
+ private val nowPlayingFormatListeners =
143
+ ListenerRegistry<(Variant_NullType_NowPlayingFormat?) -> Unit>()
144
+
145
+ /**
146
+ * Cached snapshot of the active item's audio format. Returned
147
+ * synchronously by [getNowPlayingFormat]. Null between queue-end /
148
+ * pre-first-track and the first successful resolve.
149
+ */
150
+ @Volatile
151
+ private var lastEmittedFormat: NowPlayingFormat? = null
152
+
153
+ /**
154
+ * Identity key for [lastEmittedFormat]'s source MediaItem. Compared
155
+ * to the player's current MediaItem on resolve to discard a result
156
+ * whose source has been rotated out.
157
+ */
158
+ private var lastEmittedFormatMediaItem: MediaItem? = null
159
+
160
+ // The fields below are written on main (via `onMain` / `syncMain`
161
+ // from the Promise adapter path, or directly from Robolectric tests
162
+ // which run on main). They are read off-main by the synchronous
163
+ // snapshot accessors — `getCurrentTrackIndex`, `getQueue` —
164
+ // dispatched on the JS thread by Nitro. `@Volatile` gives the
165
+ // read path a happens-before edge on the most recent main-thread
166
+ // write under the JVM memory model. Without it, JS readers could
167
+ // observe stale values indefinitely.
168
+ //
169
+ // Caveat on multi-field consistency: each field is independently
170
+ // volatile, but the pair (`tracks`, `currentTrackIndex`) is NOT
171
+ // atomic across a mutation. A JS caller that reads `getQueue()`
172
+ // and `getCurrentTrackIndex()` in the same pass can observe the
173
+ // new list with the old index (or vice versa) for one tick while
174
+ // a main-thread mutation is mid-write. Consumers needing a
175
+ // consistent snapshot should call a single accessor (e.g. a
176
+ // future `getQueueSnapshot()` that returns both under one lock),
177
+ // or tolerate the one-tick skew. This constraint inherits from
178
+ // the iOS sibling, which shields the pair only via Promise
179
+ // ordering on main.
180
+ //
181
+ // `config` + `repeatModeState` are currently read only on main but
182
+ // carry the same annotation for uniformity.
183
+
184
+ @Volatile
185
+ @VisibleForTesting
186
+ internal var tracks: List<TrackItem> = emptyList()
187
+
188
+ @Volatile
189
+ @VisibleForTesting
190
+ internal var currentTrackIndex: Int = -1
191
+
192
+ @Volatile
193
+ @VisibleForTesting
194
+ internal var config: PlayerConfig =
195
+ PlayerConfig(
196
+ httpHeaders = null,
197
+ userAgent = null,
198
+ autoRetries = null,
199
+ retryBackoffMs = null,
200
+ networkTimeoutMs = null,
201
+ audioContentType = null,
202
+ audioCategoryOptions = null,
203
+ visualizationEnabled = null,
204
+ clampSeekToBuffered = null,
205
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
206
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
207
+ )
208
+
209
+ @Volatile
210
+ @VisibleForTesting
211
+ internal var repeatModeState: RepeatMode = RepeatMode.OFF
212
+
213
+ /**
214
+ * Canonical volume + playback speed, independent of the active
215
+ * engine. Setters update these and forward to the engine; getters
216
+ * read these (so a mid-fade ramp on the crossfade leg doesn't leak
217
+ * out); the engine swap re-applies them so a gapless↔crossfade swap
218
+ * preserves the user's settings.
219
+ */
220
+ @VisibleForTesting
221
+ internal var volumeState: Float = 1.0f
222
+
223
+ @VisibleForTesting
224
+ internal var playbackSpeedState: Float = 1.0f
225
+
226
+ // Lock-screen / notification remote-control config (seconds). Pushed to the
227
+ // service on set + re-read by `getRemoteControls`.
228
+ private var remoteSkipMode: RemoteSkipMode = RemoteSkipMode.TRACK
229
+ private var remoteForwardSeconds: Double = 15.0
230
+ private var remoteBackwardSeconds: Double = 15.0
231
+
232
+ /**
233
+ * Canonical pitch-correction mode, independent of the active engine.
234
+ * Re-applied at configure + engine swap so it survives a
235
+ * destroy→configure cycle and a gapless↔crossfade swap. Default voice.
236
+ */
237
+ @Volatile
238
+ @VisibleForTesting
239
+ internal var pitchCorrectionModeState: PitchCorrectionMode = PitchCorrectionMode.VOICE
240
+
241
+ /**
242
+ * Mode-switch state machine + current state. Driven by
243
+ * [setPlaybackMode]; consumed by [getPlaybackMode] (returns
244
+ * `state.active`) and the boundary-fire listener in the
245
+ * [PlaybackEngineDelegate.onTrackTransition] handler.
246
+ */
247
+ private val playbackModeStateMachine = PlaybackModeStateMachine()
248
+
249
+ @Volatile
250
+ private var playbackModeState: PlaybackModeStateMachine.State =
251
+ PlaybackModeStateMachine.Initial
252
+
253
+ /**
254
+ * Subscribe to cast-route activate/deactivate transitions.
255
+ * Push-PCM sessions (AirPlay 1/2) force-degrade the active engine
256
+ * from Crossfade to Gapless because the wire protocol cannot mix
257
+ * two ExoPlayers into one sink; clearing the session re-applies
258
+ * the user's requested mode (so crossfade re-instates).
259
+ *
260
+ * The disposer is intentionally discarded — TrackPlayer is a
261
+ * process-singleton `HybridObject`; the listener lives for the
262
+ * lifetime of the JVM and the disposer would only matter if a
263
+ * second TrackPlayer instance ever existed.
264
+ */
265
+ @Suppress("unused")
266
+ private val castRouteDisposer: () -> Unit =
267
+ com.margelo.nitro.queueplayer.cast.CastSinkRouter.addRouteChangeListener { _ ->
268
+ // Re-apply against the user's requested mode on every flip.
269
+ // `applyEngineSwapForMode` consults `CastSinkRouter.isRemoteActive`
270
+ // for the degrade decision, so the swap settles to the right
271
+ // engine for the new route state.
272
+ val mode = playbackModeState.active
273
+ mainHandler.post { applyEngineSwapForMode(mode) }
274
+ }
275
+
276
+ /**
277
+ * Bridges Chromecast `RemotePlayer` session events back to the
278
+ * lib's existing JS event surface (`onStateChange` etc.). Lives for
279
+ * the process lifetime; the bridge's own scope observes
280
+ * `PlaybackStateRouter.activeFlow` and subscribes / unsubscribes to
281
+ * the active session's StateFlows on every transition.
282
+ */
283
+ @Suppress("unused")
284
+ private val castEventBridge = com.margelo.nitro.queueplayer.cast.CastEventBridge(this).also {
285
+ it.start()
286
+ }
287
+
288
+ /**
289
+ * Main-thread Handler reused across every `onMain` hop. ExoPlayer
290
+ * access (construction, property reads/writes, release) must all
291
+ * happen on the main Looper, so the transport / queue / skip
292
+ * tasks that follow all go through the same helper — cache the
293
+ * handler once rather than allocating per call.
294
+ */
295
+ private val mainHandler = Handler(Looper.getMainLooper())
296
+
297
+ /**
298
+ * Main-dispatched scope for the AirPlay receiver-display metadata sync.
299
+ * `Main.immediate` because the sync's progress loop reads ExoPlayer state
300
+ * (isPlaying / position), which must run on the app looper. Process-lifetime,
301
+ * matching this singleton's other long-lived cast hooks; individual syncs are
302
+ * cancelled by `MetadataAwareSession.onDeactivated()`.
303
+ */
304
+ private val metadataSyncScope =
305
+ kotlinx.coroutines.CoroutineScope(
306
+ kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate
307
+ )
308
+
309
+ /**
310
+ * Connection to the in-process [PlaybackService]. `onServiceConnected`
311
+ * stashes the [PlaybackService.LocalBinder] and completes
312
+ * [serviceReady] only when [boundContext] is non-null — a stale
313
+ * connection callback from a prior bind cycle (configure → destroy →
314
+ * configure rapid-fire) must not complete the fresh deferred.
315
+ * `onServiceDisconnected` clears the binder + replaces [serviceReady]
316
+ * with a fresh [CompletableDeferred] so a subsequent [configure]
317
+ * re-binds cleanly.
318
+ */
319
+ private val serviceConnection = object : ServiceConnection {
320
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
321
+ if (boundContext == null) return
322
+ val typedBinder = binder as? PlaybackService.LocalBinder
323
+ serviceBinder = typedBinder
324
+ // Reset the auto-rebind backoff state — successful connect
325
+ // means any pending rebound from a prior disconnect has
326
+ // landed; the next disconnect starts the backoff fresh.
327
+ rebindAttempt = 0
328
+ rebindHandler.removeCallbacks(rebindRunnable)
329
+ // Apply any browse-callback registrations the consumer made
330
+ // before the binder was available — typically `bootstrap.ts`
331
+ // → `installCarService` → `registerPlaybackService` running
332
+ // at bundle-load time, before `configure()` binds the service.
333
+ // Without this drain, those pre-bind registrations are dropped
334
+ // and Android Auto's later bind times out waiting for the JS
335
+ // carConnect callback.
336
+ drainPendingBrowseRegistrations()
337
+ drainPendingSnapshot()
338
+ // Re-apply the remote-control config to the (possibly reborn) service.
339
+ // TrackPlayer is the source of truth; a fresh PlaybackService starts at
340
+ // the track-skip / 15s default, and config set before the first bind
341
+ // never reached a service, so re-push on every connect.
342
+ typedBinder?.setRemoteControls(
343
+ remoteSkipMode,
344
+ (remoteForwardSeconds * 1000).toLong(),
345
+ (remoteBackwardSeconds * 1000).toLong(),
346
+ )
347
+ // Same re-push for the seek-clamp config — a reborn service starts at the
348
+ // default (off) and config set before the first bind never reached one.
349
+ typedBinder?.setClampSeekToBuffered(config.clampSeekToBuffered == true)
350
+ serviceReady.complete(Unit)
351
+ // Service-rebirth detection: compare the new binder's stable
352
+ // identity against the last-seen one. A different `instanceId`
353
+ // means the OS killed + re-created the service (memory
354
+ // reclaim, doze, idle-bucket reaper) — the consumer should
355
+ // re-push the browse snapshot because the new service starts
356
+ // with an empty `BrowseDataProvider`. The first bind in a
357
+ // JS-process lifetime fires `first-bind`; subsequent rebinds
358
+ // against a new instance fire `service-reborn`.
359
+ typedBinder?.let { tb ->
360
+ val reason = decideReadyReason(lastSeenBinderInstanceId, tb.instanceId)
361
+ lastSeenBinderInstanceId = tb.instanceId
362
+ if (reason != null) {
363
+ tb.service.browseCallbacks.serviceReadyCallback()?.invoke(reason)
364
+ }
365
+ }
366
+ }
367
+
368
+ override fun onServiceDisconnected(name: ComponentName?) {
369
+ serviceBinder = null
370
+ serviceReady = CompletableDeferred()
371
+ // Intentionally NOT clearing `lastSeenBinderInstanceId` here —
372
+ // the next `onServiceConnected` compares against the prior id
373
+ // to detect rebirth. Clearing would force every reconnect to
374
+ // look like a fresh first-bind.
375
+ //
376
+ // The OS-driven service kill (memory reclaim, doze, idle
377
+ // bucket reaper) fires this callback shortly before rebuilding
378
+ // the service. Kick off the rebind watchdog so the lib reaches
379
+ // the freshly-rebuilt service automatically; without this the
380
+ // consumer's JS-side carService is left talking to a dead
381
+ // binder reference until the user explicitly re-binds.
382
+ scheduleRebindIfNeeded()
383
+ }
384
+ }
385
+
386
+ /** Last `LocalBinder.instanceId` observed by
387
+ * [serviceConnection.onServiceConnected]. Used to distinguish
388
+ * first-bind from service-reborn on the next connect. `@Volatile`
389
+ * so the OS-thread connection callback sees the latest value
390
+ * written by a prior callback on a different thread. */
391
+ @Volatile
392
+ private var lastSeenBinderInstanceId: java.util.UUID? = null
393
+
394
+ /** Auto-rebind watchdog state. Capped retries with exponential
395
+ * backoff matched against `ensureServiceBound`'s 5s timeout —
396
+ * the worst case 5+1+0.2 ≈ 6s total schedule covers the OS's
397
+ * typical service-restart window without burning a bind storm
398
+ * on a permanently-failing service. */
399
+ private val rebindBackoffsMs = longArrayOf(200, 500, 1_000, 2_000, 5_000)
400
+ @Volatile
401
+ private var rebindAttempt: Int = 0
402
+ private val rebindHandler =
403
+ android.os.Handler(android.os.Looper.getMainLooper())
404
+ private val rebindRunnable = Runnable { attemptRebind() }
405
+
406
+ /** Schedule a `bindService` retry after a service-disconnect.
407
+ * No-op when the consumer has destroyed the player
408
+ * (`boundContext == null`) or when a retry is already queued
409
+ * (idempotent). */
410
+ @MainThread
411
+ private fun scheduleRebindIfNeeded() {
412
+ if (boundContext == null) return
413
+ val attempt = rebindAttempt
414
+ if (attempt >= rebindBackoffsMs.size) {
415
+ emitRebindFailedError(
416
+ "Service rebind gave up after ${rebindBackoffsMs.size} attempts"
417
+ )
418
+ rebindAttempt = 0
419
+ return
420
+ }
421
+ rebindHandler.removeCallbacks(rebindRunnable)
422
+ rebindHandler.postDelayed(rebindRunnable, rebindBackoffsMs[attempt])
423
+ rebindAttempt = attempt + 1
424
+ android.util.Log.i(
425
+ "RNQP-Rebind",
426
+ "Scheduling service rebind attempt ${rebindAttempt}/" +
427
+ "${rebindBackoffsMs.size} in ${rebindBackoffsMs[attempt]}ms"
428
+ )
429
+ }
430
+
431
+ /** Fire one bind attempt. Called on the main looper from
432
+ * [rebindHandler]. The bind result is asynchronous — successful
433
+ * binds land in [serviceConnection.onServiceConnected] which
434
+ * resets `rebindAttempt`; failed binds schedule the next backoff
435
+ * step via the same watchdog path. */
436
+ @MainThread
437
+ private fun attemptRebind() {
438
+ val ctx = boundContext ?: return
439
+ if (serviceBinder != null) {
440
+ // Race: a successful bind landed between scheduling + firing.
441
+ // Reset the counter and bail.
442
+ rebindAttempt = 0
443
+ return
444
+ }
445
+ val intent = Intent(ctx, PlaybackService::class.java)
446
+ .setAction(PlaybackService.ACTION_LOCAL_BIND)
447
+ val didBind = try {
448
+ ctx.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
449
+ } catch (t: Throwable) {
450
+ android.util.Log.w(
451
+ "RNQP-Rebind",
452
+ "bindService threw on rebind attempt ${rebindAttempt}: ${t.message}"
453
+ )
454
+ false
455
+ }
456
+ if (!didBind) {
457
+ // bindService returned false (or threw) — schedule the next
458
+ // backoff step. The watchdog gives up after the array is
459
+ // exhausted.
460
+ scheduleRebindIfNeeded()
461
+ }
462
+ // didBind == true → `onServiceConnected` will fire and reset
463
+ // `rebindAttempt`. If it never fires within reason the next
464
+ // disconnect would re-arm the watchdog; the connection-side
465
+ // timeout sits inside `ensureServiceBound`'s 5s await, which
466
+ // is the configure-path concern.
467
+ }
468
+
469
+ /** Cancel any pending rebind attempt. Called from
470
+ * [destroyInternal] so a teardown-during-rebind cycle doesn't
471
+ * leak a delayed runnable. */
472
+ @MainThread
473
+ private fun cancelPendingRebind() {
474
+ rebindHandler.removeCallbacks(rebindRunnable)
475
+ rebindAttempt = 0
476
+ }
477
+
478
+ /** Surface a permanent rebind failure to JS via the standard
479
+ * error channel. Same shape as [emitBindFailedError] but tagged
480
+ * `PLAYBACK_SERVICE_REBIND_FAILED` so the consumer can
481
+ * distinguish initial-bind failures from rebirth-recovery
482
+ * failures. */
483
+ private fun emitRebindFailedError(reason: String) {
484
+ val err = PlaybackError(
485
+ code = PlaybackErrorCode.UNKNOWN,
486
+ message = "PlaybackService rebind failed",
487
+ fatal = false,
488
+ nativeCode = 0.0,
489
+ nativeDomain = "PLAYBACK_SERVICE_REBIND_FAILED",
490
+ nativeMessage = reason,
491
+ queueItemId = "",
492
+ url = "",
493
+ )
494
+ errorListeners.forEach { it(err) }
495
+ }
496
+
497
+ /**
498
+ * Live binder handle once the service is bound. Read on main
499
+ * during [configureInternal] and on whatever thread calls
500
+ * [destroyInternal]; `@Volatile` keeps the connection-callback
501
+ * write visible to those readers without a lock.
502
+ */
503
+ @Volatile
504
+ private var serviceBinder: PlaybackService.LocalBinder? = null
505
+
506
+ /**
507
+ * Completes when the service binds successfully. [ensureServiceBound]
508
+ * awaits this with a 5s timeout. Replaced with a fresh deferred on
509
+ * disconnect or [destroyInternal] so the next bind cycle starts from
510
+ * a pending state.
511
+ */
512
+ @Volatile
513
+ private var serviceReady: CompletableDeferred<Unit> = CompletableDeferred()
514
+
515
+ /**
516
+ * Application context used for the active bind, kept so
517
+ * [destroyInternal] can call `unbindService` against the same
518
+ * context that originally called `bindService`.
519
+ */
520
+ @Volatile
521
+ private var boundContext: Context? = null
522
+
523
+ // --- Events — callback + observer state
524
+ //
525
+ // Mirrors iOS `TrackPlayer.swift`'s "Events" block. Five
526
+ // registration methods (onStateChange / onTrackChange /
527
+ // onProgress / onError / onQueueEnd) each store a single
528
+ // callback and return an unsubscribe closure.
529
+ //
530
+ // State + track change flow out of Player.Listener. Progress
531
+ // flows out of a Handler-based periodic tick at 2 Hz (matches
532
+ // iOS `addPeriodicTimeObserver` rate; NOTES.md §5 names this as
533
+ // load-bearing for backgrounded playback). Error flows out of
534
+ // Player.Listener.onPlayerError. Queue end fires when the
535
+ // player reaches STATE_ENDED with a non-empty track list (no
536
+ // repeat mode wrapping).
537
+ //
538
+ // All callback reads + writes happen on main via `onMainSync` —
539
+ // registration/unregistration from a JS caller funnels through
540
+ // that, and every listener body already runs on the
541
+ // applicationLooper (main) by Media3 contract.
542
+ //
543
+ // Multi-listener registries — each `onXxx(callback)` registers an
544
+ // entry, returns a dispose closure that removes by id. Lets
545
+ // multiple consumers (Player UI, harness, test runner) subscribe
546
+ // simultaneously without clobbering each other.
547
+ private val stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Unit>()
548
+ private val trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Unit>()
549
+ private val progressListeners = ListenerRegistry<(PlayerProgress) -> Unit>()
550
+ // Buffer state — discrete empty/buffering/stalled/full for the active track, derived
551
+ // from Media3's playback state + buffered-ahead. Recomputed on the engine
552
+ // state-change delegate (sub-tick latency for STATE_BUFFERING↔READY) + the
553
+ // progress tick; emitted only on an actual change.
554
+ private val bufferStateListeners = ListenerRegistry<(BufferState) -> Unit>()
555
+ @Volatile private var currentBufferState: BufferState = BufferState.EMPTY
556
+ private var lastEmittedBufferState: BufferState? = null
557
+ // True once the current track has actually reached a playing state — the
558
+ // discriminator between an initial `buffering` load and a mid-playback
559
+ // `stalled` rebuffer. Reset on track change / stop / clear.
560
+ @Volatile private var hasStartedPlaying: Boolean = false
561
+ // Lib-level playback intent (play() → true, pause()/stop() → false). A
562
+ // not-full buffer only reads as `stalled` while playback is both started and
563
+ // intended; a paused load reads as `buffering`.
564
+ @Volatile private var wantsToPlay: Boolean = false
565
+ // Fully-buffered — the whole track has finished downloading (bufferedPosition
566
+ // reaches duration). Distinct from BufferState.full ("keeps up"). Recomputed
567
+ // on the progress tick (bufferedPosition grows continuously) + track change;
568
+ // emitted on change. Reset per track.
569
+ private val fullyBufferedListeners = ListenerRegistry<(Boolean) -> Unit>()
570
+ @Volatile private var currentFullyBuffered: Boolean = false
571
+ private var lastEmittedFullyBuffered: Boolean? = null
572
+ // (milestone, trackIndex) — 25/50/75/90% playback thresholds.
573
+ private val milestoneListeners = ListenerRegistry<(Double, Double) -> Unit>()
574
+ // Per-playthrough milestone state. Main-thread-confined (mutated only
575
+ // from the progress runnable, seekToInternal, and the track-change
576
+ // handlers — all on main), so no extra synchronisation is needed.
577
+ private val milestoneTracker = MilestoneTracker()
578
+ // Throttles onProgress JS emission to the configured foreground / background
579
+ // cadence. Main-thread-confined (mutated only from the progress runnable +
580
+ // the ProcessLifecycleOwner callbacks, all on main).
581
+ private val progressEmissionGate = ProgressEmissionGate()
582
+ // ProcessLifecycleOwner observer driving the gate's background state. Held in
583
+ // a nullable field so a re-configureInternal without an intervening
584
+ // destroyInternal (Robolectric) is a no-op instead of leaking a second
585
+ // observer. Registered in configureInternal, removed in destroyInternal.
586
+ private var progressLifecycleObserver: DefaultLifecycleObserver? = null
587
+ private val errorListeners = ListenerRegistry<(PlaybackError) -> Unit>()
588
+ private val queueEndListeners = ListenerRegistry<() -> Unit>()
589
+ // SkipCapability struct — wraps (canSkipPrevious, canSkipNext).
590
+ // Struct rather than separate boolean args dodges a Nitrogen
591
+ // 0.35.4 (bool, bool) callback codegen bug.
592
+ private val skipCapabilityListeners = ListenerRegistry<(SkipCapability) -> Unit>()
593
+
594
+ /**
595
+ * Last-emitted skip capability tuple. Native owns the truth; the
596
+ * JS layer is a passive observer. Recomputed via
597
+ * [recomputeCapabilities] at every `tracks` / `currentTrackIndex`
598
+ * / `repeatModeState` write site, with dedup.
599
+ */
600
+ /**
601
+ * Authoritative snapshot of (canSkipPrevious, canSkipNext) — single
602
+ * `@Volatile` reference write keeps the pair atomic across cross-
603
+ * thread reads. JS getters read this once. Recomputed by
604
+ * [recomputeCapabilities] at every `tracks` / `currentTrackIndex`
605
+ * / `repeatModeState` write site, with dedup — the
606
+ * `skipCapabilityListeners` registry fires only when the pair
607
+ * actually changes.
608
+ */
609
+ @Volatile
610
+ private var cachedSkipCapability: SkipCapability =
611
+ SkipCapability(canSkipPrevious = false, canSkipNext = false)
612
+
613
+ // --- Sleep timer (native, engine-agnostic — survives engine swaps) ---
614
+ private val sleepTimerCore = SleepTimerCore()
615
+ private val sleepTimerListeners = ListenerRegistry<(SleepTimerState) -> Unit>()
616
+ private var sleepTimerRunnable: Runnable? = null
617
+ // True while playback volume is currently ramped down by the timer's fade, so
618
+ // we know a restore to `volumeState` is owed on clear/fire.
619
+ private var sleepTimerFading = false
620
+ // Set just before the fire calls `engine.pause()`. That pause reaches Media3
621
+ // as a USER_REQUEST play-when-ready change, which `onStateMaybeChanged`
622
+ // stamps over `pendingStateChangeReason`; this one-shot flag lets
623
+ // `emitStateIfChanged` re-label the resulting PAUSED emit as 'sleep-timer'.
624
+ private var pendingSleepTimerPause = false
625
+
626
+ /**
627
+ * Tracks the most recently published "queue has any media items"
628
+ * state so [recomputeCapabilities] can detect empty→non-empty
629
+ * transitions even when the skip-capability pair didn't change
630
+ * (e.g. setQueue replaces an empty queue with a single-track list:
631
+ * skip pair stays `(false, false)` but the action mask must flip
632
+ * to include PLAY again). Initial `false` matches the empty queue
633
+ * the lib starts with.
634
+ */
635
+ private var lastHasMediaItemsReported: Boolean = false
636
+
637
+ /**
638
+ * Cached classification of the active track's playback source.
639
+ * Recomputed in [handleMediaItemTransition] whenever the active
640
+ * track changes. Read by [getCurrentTrackSource] for sync JS access.
641
+ * `@Volatile` mirrors the other JS-visible cached snapshots
642
+ * ([cachedSkipCapability]).
643
+ */
644
+ @Volatile
645
+ private var currentTrackSource: TrackSource? = null
646
+
647
+ /**
648
+ * Records `System.nanoTime()` when Media3 fires
649
+ * `onPositionDiscontinuity(REASON_AUTO_TRANSITION)` — i.e. when
650
+ * the engine internally moves from the end of one track to the
651
+ * start of the next. The matching `onMediaItemTransition` callback
652
+ * fires shortly after; the delta surfaces as `onTrackChange`'s
653
+ * `transitionGapMs`. Read + written on main only.
654
+ */
655
+ private var gaplessGapPrevEndedAtNs: Long = 0L
656
+
657
+ /**
658
+ * Debug-only timestamps backing the verbose `RNQP-GAPLESS` Log.d
659
+ * emits. Wrapped in `if (BuildConfig.DEBUG)` at every read/write
660
+ * site so R8 strips the branches in Release builds. Field storage
661
+ * stays (16 bytes total — negligible) but is never accessed in prod.
662
+ */
663
+ private var gaplessLogLastEndedAtNs: Long = 0L
664
+ private var gaplessLogLastChangedAtNs: Long = 0L
665
+
666
+ /** Stashed by transport methods before calling player.play()/pause()/stop()/seekTo().
667
+ * Consumed by [emitStateIfChanged] on the next listener fire that
668
+ * produces a non-buffering terminal state. Defaults to [StateChangeReason.SYSTEM]
669
+ * when no stash is pending (covers auto-advance, interruptions, etc.).
670
+ *
671
+ * Read + written on main only — no volatility needed. */
672
+ @VisibleForTesting
673
+ internal var pendingStateChangeReason: StateChangeReason? = null
674
+
675
+ /** Stashed by skip methods before issuing `player.seekTo(index, 0L)`.
676
+ * Consumed by [handleMediaItemTransition] when Media3 fires a
677
+ * REASON_SEEK transition. Defaults to [TrackChangeReason.AUTO_ADVANCE]
678
+ * for natural end-of-track advances. */
679
+ @VisibleForTesting
680
+ internal var pendingTrackChangeReason: TrackChangeReason? = null
681
+
682
+ /** Set to the incoming-track index by [onCrossfadeBegin] the
683
+ * instant a crossfade fade-in begins. Consumed by
684
+ * [handleMediaItemTransition] to dedup the structural Media3
685
+ * echo that fires post-`swapRoles` for the same incoming index.
686
+ * Cleared on consume + on every non-AUTO transition path. */
687
+ @Volatile
688
+ private var crossfadeFadeStartEmittedIdx: Int? = null
689
+
690
+ /** Last (currentTrackIndex, standardized PlaybackErrorCode) tuple
691
+ * emitted via [errorListeners]. Used by [handlePlayerError] to
692
+ * dedup repeated events from a retry storm (ExoPlayer's
693
+ * transient-error retry firing onPlayerError multiple times for
694
+ * the same item + cause). Mirrors iOS `lastErrorQueueItemId` +
695
+ * `lastErrorCode` at TrackPlayer.swift.
696
+ *
697
+ * Why `currentTrackIndex` (lib-internal authoritative) and not
698
+ * `currentMediaItem.mediaId` (consumer-supplied): consumer
699
+ * `track.id` may duplicate across queue positions, so two
700
+ * distinct queue positions with the same id + same code would
701
+ * incorrectly dedup. Lib-internal index is unique per position
702
+ * and naturally clears on track change.
703
+ *
704
+ * Cleared on track-change ([handleMediaItemTransition]),
705
+ * setQueue, clearQueue, [destroyInternal], AND on retry-
706
+ * initiation so the retry-then-fail can fire a fresh error. */
707
+ private var lastErrorKey: Pair<Int, PlaybackErrorCode>? = null
708
+
709
+ /** Auto-retry counter per queue position, keyed on
710
+ * `currentTrackIndex` (lib-internal authoritative position; never
711
+ * derived from consumer fields). Decremented on each
712
+ * transient-error fire; when 0, the error surfaces to JS.
713
+ * Re-populated on `setQueueInternal` /
714
+ * `addToQueueInternal` from `effectiveAutoRetries()` (clamped
715
+ * 0..5; default 3). Reset to 1 (single shot) by `play()` if state
716
+ * is `.error` and the counter is 0 — stuck-recovery on user-
717
+ * initiated re-tap. Cleared on `removeFromQueue` / `moveInQueue`
718
+ * (index shifts invalidate keys; budget resets are an acceptable
719
+ * trade-off vs walking + remapping the map per shifted index). */
720
+ private var retryAttemptsRemaining: MutableMap<Int, Int> = mutableMapOf()
721
+
722
+ /** Last [PlayerState] emitted to subscribers. Suppresses duplicate
723
+ * fires — Media3 listener can emit several status transitions
724
+ * that collapse onto the same PlayerState. */
725
+ private var lastReportedState: PlayerState = PlayerState.NONE
726
+
727
+ /** Engine TrackPlayer last set itself as delegate on. Stored so
728
+ * `tearDownEventObservers` clears the same delegate slot the
729
+ * install path filled, even though within a single configure
730
+ * cycle install + teardown always run against the same engine
731
+ * instance. Nullable because configure wires it once; destroy
732
+ * clears it before releasing. */
733
+ private var engineDelegateRegistration: PlaybackEngine? = null
734
+
735
+ /** Runnable that re-posts itself every 500 ms to tick progress.
736
+ * Started when the first onProgress subscriber registers, paused
737
+ * when none remain. Goes away in destroy. */
738
+ private var progressTickRunnable: Runnable? = null
739
+
740
+ /** True while [progressTickRunnable] is actively posting. Guards
741
+ * against double-start races. */
742
+ private var progressTickActive: Boolean = false
743
+
744
+ /** Serialises `configure()` and `destroy()` so two concurrent
745
+ * invocations cannot interleave their teardown / rebuild steps.
746
+ * Each path's body executes under [withLock], so a configure that
747
+ * is mid-`destroyInternal` cannot be re-entered by a second
748
+ * configure while the first is still in `ensureServiceBound` /
749
+ * `configureInternal`, and a destroy fired during a configure
750
+ * waits until the rebuild completes before tearing down again. */
751
+ private val lifecycleMutex = Mutex()
752
+
753
+ // --- Lifecycle
754
+
755
+ override fun configure(config: PlayerConfig): Promise<Unit> =
756
+ Promise.async {
757
+ lifecycleMutex.withLock {
758
+ // Read `applicationContext` on main — the field is written
759
+ // from the React Native init path and has no documented
760
+ // thread-safety guarantee; reading here also serialises
761
+ // configure/destroy on the same main-thread lock step.
762
+ val context = onMain {
763
+ NitroModules.applicationContext
764
+ ?: throw IllegalStateException(
765
+ "[QP_NOT_INSTALLED] NitroModules.applicationContext is null - " +
766
+ "TrackPlayer.configure called before NitroModules was installed " +
767
+ "by React Native."
768
+ )
769
+ }
770
+ // Universal auto-destroy contract: every `configure()` call
771
+ // tears down any prior engine, service binding, and pipeline
772
+ // state before reinitialising. On the first configure of a
773
+ // session this is a no-op (`destroyInternal` short-circuits
774
+ // when `player == null` + `boundContext == null`). On
775
+ // subsequent calls it is a real teardown — including
776
+ // `unbindService` — so the service is re-bound below for the
777
+ // new config. Effect: configure is always a clean-slate
778
+ // operation; consumers don't need to remember to call
779
+ // `destroy()` before re-configuring.
780
+ onMain { destroyInternal() }
781
+ ensureServiceBound(context)
782
+ onMain { configureInternal(config, context) }
783
+ }
784
+ }
785
+
786
+ override fun destroy(): Promise<Unit> =
787
+ Promise.async {
788
+ lifecycleMutex.withLock {
789
+ onMain { destroyInternal() }
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Bind the in-process [PlaybackService] so the player is owned by
795
+ * the service rather than constructed inline by [configureInternal].
796
+ * Returns immediately once [serviceBinder] is non-null; otherwise
797
+ * issues `bindService(BIND_AUTO_CREATE)` and awaits the connection
798
+ * callback with a 5s timeout. Called from [configure] before
799
+ * [configureInternal] so the synchronous `@MainThread` body can
800
+ * assume [serviceBinder] is non-null.
801
+ *
802
+ * Bind failure surfaces twice: as a typed [PlaybackError] on
803
+ * [errorListeners] (`nativeDomain = "PLAYBACK_SERVICE_BIND_FAILED"`)
804
+ * for JS consumers subscribed to `onError`, and as a thrown
805
+ * exception so the calling [configure] Promise rejects.
806
+ */
807
+ @VisibleForTesting
808
+ internal suspend fun ensureServiceBound(context: Context) {
809
+ if (serviceBinder != null) return
810
+ onMain {
811
+ if (boundContext == null) {
812
+ val appContext = context.applicationContext
813
+ val intent = Intent(appContext, PlaybackService::class.java)
814
+ .setAction(PlaybackService.ACTION_LOCAL_BIND)
815
+ // Stash the context BEFORE bindService so the callback's
816
+ // `boundContext != null` guard sees this cycle's bind. Cleared
817
+ // back to null below if bindService rejects.
818
+ boundContext = appContext
819
+ val didBind = appContext.bindService(
820
+ intent, serviceConnection, Context.BIND_AUTO_CREATE
821
+ )
822
+ if (!didBind) {
823
+ boundContext = null
824
+ serviceReady.completeExceptionally(
825
+ IllegalStateException("PlaybackService bindService returned false")
826
+ )
827
+ }
828
+ }
829
+ }
830
+ val ready = withTimeoutOrNull(5_000) {
831
+ runCatching { serviceReady.await() }
832
+ }
833
+ if (ready == null) {
834
+ emitBindFailedError("Timed out after 5000ms waiting for onServiceConnected")
835
+ throw IllegalStateException(
836
+ "PlaybackService bindService timed out after 5000ms"
837
+ )
838
+ }
839
+ val ex = ready.exceptionOrNull()
840
+ if (ex != null) {
841
+ emitBindFailedError(ex.message ?: ex::class.java.simpleName)
842
+ throw ex
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Emit a `PLAYBACK_SERVICE_BIND_FAILED` PlaybackError to JS
848
+ * consumers subscribed to `onError`. Non-fatal at the lib layer —
849
+ * the consuming `configure()` / mutation Promise still rejects so
850
+ * the consumer's await chain breaks. The error event lets a top-
851
+ * level UI surface the failure (manifest typo, packaging issue,
852
+ * service crash on first launch) without parsing rejection text.
853
+ */
854
+ private fun emitBindFailedError(reason: String) {
855
+ val err = PlaybackError(
856
+ code = PlaybackErrorCode.UNKNOWN,
857
+ message = "PlaybackService bind failed",
858
+ fatal = false,
859
+ nativeCode = 0.0,
860
+ nativeDomain = "PLAYBACK_SERVICE_BIND_FAILED",
861
+ nativeMessage = reason,
862
+ queueItemId = "",
863
+ url = "",
864
+ )
865
+ errorListeners.forEach { it(err) }
866
+ }
867
+
868
+ /**
869
+ * Lifecycle configure body. `internal` so Robolectric tests can
870
+ * exercise it without the Nitro Promise path. Callers MUST hop
871
+ * to the main thread first — ExoPlayer construction binds to the
872
+ * calling Looper. Production path goes through `onMain`; tests
873
+ * run on Robolectric's main-thread dispatcher.
874
+ *
875
+ * Builds the ExoPlayer with a [MediaSource.Factory] that attaches
876
+ * the config's HTTP headers + User-Agent on every network request.
877
+ * The factory is bound to the ExoPlayer at construction time —
878
+ * Media3 does not support swapping factories on a live instance.
879
+ * Per the universal auto-destroy contract every `configure()` call
880
+ * tears the player down upstream so this body is purely
881
+ * constructive — `player` is guaranteed null on entry from the
882
+ * production path. Robolectric tests that drive `configureInternal`
883
+ * directly are responsible for calling `destroyInternal` first
884
+ * between configures.
885
+ */
886
+ @VisibleForTesting
887
+ @MainThread
888
+ internal fun configureInternal(newConfig: PlayerConfig, context: Context) {
889
+ // Eviction policy is fixed at SimpleCache construction; a change clears the
890
+ // cache + rebuilds under the new evictor (infrequent — see FifoCacheEvictor).
891
+ // Safe to release the shared SimpleCache here: configure() runs
892
+ // destroyInternal() before this, which releases the prior engine — the only
893
+ // CacheDataSource reader of that cache — so there is no live reader when the
894
+ // shared instance is released and its directory deleted below.
895
+ val previousEvictionPolicy = evictionPolicyFrom(this.config)
896
+ this.config = newConfig
897
+ if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
898
+ lookaheadCacheWriter?.release()
899
+ lookaheadCacheWriter = null
900
+ lookaheadCache?.release()
901
+ lookaheadCache = null
902
+ val cacheDir = java.io.File(context.cacheDir, LookaheadCache.DEFAULT_CACHE_DIR_NAME)
903
+ LookaheadCache.releaseSharedInstance(cacheDir)
904
+ cacheDir.deleteRecursively()
905
+ }
906
+ // Seed the progress-emission throttle from config and start observing app
907
+ // background/foreground. Both are idempotent, so a re-configure refreshes
908
+ // the rate without double-registering the lifecycle observer.
909
+ applyProgressEmissionIntervals()
910
+ installProgressLifecycleObserver()
911
+ // Pin-at-configure visualization-disabled flag. Mirrored to the
912
+ // `VisualizerConfig` singleton so the engines' field-init read
913
+ // (below, via `binder.service.configurePlayer` which constructs a
914
+ // fresh engine) picks up the new value, and so
915
+ // `Visualizer.subscribe(...)` can read it live. Default `true`
916
+ // preserves existing behaviour for consumers who never set the
917
+ // new field.
918
+ VisualizationGate.setEnabled(newConfig.visualizationEnabled ?: true)
919
+ buildLookaheadStack(newConfig, context)
920
+ val mediaSourceFactory =
921
+ MediaItemBuilder.buildMediaSourceFactory(context, newConfig, lookaheadCache)
922
+ val binder = serviceBinder
923
+ ?: error(
924
+ "PlaybackService not bound — ensureServiceBound was not called " +
925
+ "before configureInternal"
926
+ )
927
+ // configurePlayer constructs a fresh GaplessEngine + binds it
928
+ // via swapEngineInternal. The `player` accessor reads through
929
+ // serviceBinder.engine.mediaSessionPlayer so it picks up the
930
+ // newly-installed engine on the next read — no explicit
931
+ // refresh needed here.
932
+ binder.service.configurePlayer(
933
+ factory = mediaSourceFactory,
934
+ audioContentType = config.audioContentType
935
+ )
936
+ // Mirror the seek-clamp config onto the forwarding player so a controller
937
+ // interval skip-forward obeys the same clamp as a JS seekTo.
938
+ binder.setClampSeekToBuffered(newConfig.clampSeekToBuffered == true)
939
+ configured = true
940
+ // configurePlayer always rebuilds the default GaplessEngine, so the mode
941
+ // state must reset to match. Otherwise getPlaybackMode() keeps reporting a
942
+ // stale crossfade selection after a clean-slate reconfigure while the
943
+ // engine is actually gapless — and a later setPlaybackMode(crossfade)
944
+ // would no-op against that stale state and never rebuild the
945
+ // CrossfadeEngine.
946
+ playbackModeState = PlaybackModeStateMachine.Initial
947
+ // Re-apply persisted config to the fresh engine. These survive a
948
+ // destroy→configure cycle and may be set before the first configure,
949
+ // so the first engine must pick them up — matching the engine-swap
950
+ // path. (ExoPlayer's setPlaybackSpeed is independent of play state.)
951
+ serviceBinder?.engine?.let { engine ->
952
+ engine.setRepeatMode(repeatModeState)
953
+ engine.setVolume(volumeState)
954
+ engine.setPlaybackSpeed(playbackSpeedState)
955
+ engine.setPitchCorrectionMode(pitchCorrectionModeState)
956
+ }
957
+ installEventObservers()
958
+ binder.setCommandHandler(mediaSessionCommandHandler)
959
+ current = this
960
+ }
961
+
962
+ /**
963
+ * Bridge from [PlaybackService.PlaybackCallback] back into this
964
+ * TrackPlayer. Stamps the `system` pending-reason for transports
965
+ * that the system surface drives (lock-screen, Bluetooth, Android
966
+ * Auto, hardware media keys), and routes skip-next / skip-previous
967
+ * through the queue-aware `skipTo*Internal` bodies so retry
968
+ * counters, lookahead reset, capability recomputation, and
969
+ * `pendingTrackChangeReason` (set inside the bodies) all stay
970
+ * consistent. Mirrors iOS `wireRemoteCommands`.
971
+ *
972
+ * Skip handlers always return `true`: even when the body early-
973
+ * returns (no player, empty queue), the lib has now claimed
974
+ * responsibility for the skip — Media3's default
975
+ * `seekToNextMediaItem` would also no-op against the same empty
976
+ * timeline, so suppressing the default routing avoids a redundant
977
+ * second hop without changing the user-visible result.
978
+ */
979
+ @VisibleForTesting
980
+ internal val mediaSessionCommandHandler =
981
+ object : PlaybackService.MediaSessionCommandHandler {
982
+ override fun onTransportCommand() {
983
+ pendingStateChangeReason = StateChangeReason.SYSTEM
984
+ }
985
+
986
+ override fun onSkipToNextCommand(): Boolean {
987
+ pendingStateChangeReason = StateChangeReason.SYSTEM
988
+ skipToNextInternal()
989
+ return true
990
+ }
991
+
992
+ override fun onSkipToPreviousCommand(): Boolean {
993
+ pendingStateChangeReason = StateChangeReason.SYSTEM
994
+ skipToPreviousInternal()
995
+ return true
996
+ }
997
+
998
+ override fun onMediaPlay() {
999
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1000
+ serviceBinder?.engine?.play()
1001
+ }
1002
+
1003
+ override fun onMediaPause() {
1004
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1005
+ serviceBinder?.engine?.pause()
1006
+ }
1007
+
1008
+ override fun onMediaTogglePlayPause() {
1009
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1010
+ val engine = serviceBinder?.engine ?: return
1011
+ // `playWhenReady` (intent to play) is read off the underlying
1012
+ // ExoPlayer because the engine surface intentionally doesn't
1013
+ // expose intent vs. effective playback. Toggling on
1014
+ // `isPlaying` would loop on buffering states.
1015
+ val playWhenReady = player?.playWhenReady ?: return
1016
+ if (playWhenReady) engine.pause() else engine.play()
1017
+ }
1018
+
1019
+ override fun onMediaSkipNext() {
1020
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1021
+ skipToNextInternal()
1022
+ }
1023
+
1024
+ override fun onMediaSkipPrevious() {
1025
+ pendingStateChangeReason = StateChangeReason.SYSTEM
1026
+ skipToPreviousInternal()
1027
+ }
1028
+
1029
+ override fun getCachedSkipCapability(): SkipCapability =
1030
+ cachedSkipCapability
1031
+
1032
+ override fun onAudioFocusEvent(mapping: AudioFocusErrorMapping.Mapping) {
1033
+ val err = PlaybackError(
1034
+ code = PlaybackErrorCode.UNKNOWN,
1035
+ message = mapping.message,
1036
+ fatal = false,
1037
+ nativeCode = 0.0,
1038
+ nativeDomain = mapping.nativeDomain,
1039
+ nativeMessage = mapping.message,
1040
+ queueItemId = "",
1041
+ url = "",
1042
+ )
1043
+ errorListeners.forEach { it(err) }
1044
+ }
1045
+
1046
+ override fun onBecomingNoisy() {
1047
+ val message = "Audio output route changed; Media3 has paused playback."
1048
+ val err = PlaybackError(
1049
+ code = PlaybackErrorCode.UNKNOWN,
1050
+ message = message,
1051
+ fatal = false,
1052
+ nativeCode = 0.0,
1053
+ nativeDomain = "AUDIO_BECOMING_NOISY",
1054
+ nativeMessage = message,
1055
+ queueItemId = "",
1056
+ url = "",
1057
+ )
1058
+ errorListeners.forEach { it(err) }
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Build the lookahead cache + writer and assign to `lookaheadCache`
1064
+ * and `lookaheadCacheWriter`. When the cache is disabled or its
1065
+ * construction fails (wedged / read-only directory), both fields
1066
+ * are left null and `buildMediaSourceFactory` falls back to a bare
1067
+ * HTTP DataSource chain. Caching is a performance optimisation,
1068
+ * not a correctness gate.
1069
+ */
1070
+ private fun buildLookaheadStack(config: PlayerConfig, context: Context) {
1071
+ // Build the cache regardless of `enabled` so caching can be turned on at
1072
+ // runtime (setLookaheadCacheInternal) without a configure() cycle. Media3's
1073
+ // SimpleCache fixes its size at construction and allows one instance per
1074
+ // directory, so the instance must exist up front; `enabled` then only gates
1075
+ // whether the writer prefetches into it. An unused empty cache is cheap.
1076
+ // Single source of truth for the MB → byte conversion; mirrors iOS's
1077
+ // `LookaheadCache.maxBytes(forMb:)`. Handles the multiply-before-cast bug
1078
+ // (0.5 MB request → 524288 bytes intermediate, not 0), the 1 MB floor, and
1079
+ // the NaN/±Infinity defence on JS-bridged input.
1080
+ val maxSizeBytes = LookaheadCache.maxBytesForMb(
1081
+ cacheMaxSizeMbFrom(config)
1082
+ )
1083
+ val cache: LookaheadCache? = try {
1084
+ LookaheadCache(
1085
+ context = context,
1086
+ maxSizeBytes = maxSizeBytes,
1087
+ evictionPolicy = evictionPolicyFrom(config)
1088
+ )
1089
+ } catch (err: java.io.IOException) {
1090
+ // SimpleCache construction wedged or read-only — log and run without a
1091
+ // cache.
1092
+ Log.w(
1093
+ PLAYER_LOG_TAG,
1094
+ "LookaheadCache construction failed; running without cache",
1095
+ err
1096
+ )
1097
+ null
1098
+ }
1099
+ lookaheadCache = cache
1100
+ // Writer + player factory share state via the cache, not via the
1101
+ // http factory instance. Writer is only useful when there's a
1102
+ // cache to write into — gate on both `lookaheadConfig.enabled`
1103
+ // AND a successful cache construction.
1104
+ lookaheadCacheWriter = if (lookaheadConfig.enabled && cache != null) {
1105
+ LookaheadCacheWriter.create(
1106
+ cache = cache,
1107
+ httpFactory = MediaItemBuilder.buildHttpFactory(
1108
+ headers = config.httpHeaders,
1109
+ userAgent = config.userAgent,
1110
+ networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
1111
+ config.networkTimeoutMs
1112
+ )
1113
+ )
1114
+ ).apply {
1115
+ defaultLookaheadCount = lookaheadConfig.lookaheadCount.toInt()
1116
+ .coerceAtLeast(0)
1117
+ onTrackProcessed = { onMainPostStatusEmit() }
1118
+ }
1119
+ } else {
1120
+ null
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Tear down the ExoPlayer + lookahead writer + cache instances so
1126
+ * the configure() rebuild path (headers/userAgent change) can
1127
+ * recreate them cleanly. The on-disk cache directory is preserved
1128
+ * — `SimpleCache.release()` only frees the in-memory instance and
1129
+ * its file lock; the entries stay on disk and the next
1130
+ * `LookaheadCache(...)` constructor reads them back. Mirrors the
1131
+ * SimpleCache "one-instance-per-directory" invariant captured in
1132
+ * NOTES.md.
1133
+ */
1134
+ private fun destroyPlayerKeepCacheOnDisk() {
1135
+ releasePlayerAndState(notifyTrackChange = true)
1136
+ }
1137
+
1138
+ /**
1139
+ * Lifecycle destroy body. Releases the ExoPlayer instance and
1140
+ * resets every piece of mutable state so a subsequent configure()
1141
+ * starts from the same clean slate as a fresh TrackPlayer. Safe
1142
+ * to call multiple times, and safe before configure() (no-op).
1143
+ */
1144
+ @VisibleForTesting
1145
+ @MainThread
1146
+ internal fun destroyInternal() {
1147
+ removeProgressLifecycleObserver()
1148
+ // Cancel any in-flight rebind retry before clearing boundContext
1149
+ // so the watchdog doesn't fire a doomed bind against a torn-down
1150
+ // player.
1151
+ cancelPendingRebind()
1152
+ serviceBinder?.setCommandHandler(null)
1153
+ releasePlayerAndState(notifyTrackChange = false)
1154
+ boundContext?.let { ctx ->
1155
+ try {
1156
+ ctx.unbindService(serviceConnection)
1157
+ } catch (_: RuntimeException) {
1158
+ // Service was never actually bound (IllegalArgumentException
1159
+ // when bindService returned false), or process-death edge
1160
+ // cases on OEM-modified frameworks (SecurityException). Both
1161
+ // mean "no live binding to release" — safe to swallow.
1162
+ }
1163
+ }
1164
+ boundContext = null
1165
+ serviceBinder = null
1166
+ serviceReady = CompletableDeferred()
1167
+ // Persist `config` + `repeatModeState` + `lookaheadConfig`
1168
+ // across destroy → configure so a consumer who set custom
1169
+ // values (HTTP headers / UA / repeat mode / cache size) once
1170
+ // doesn't lose them on a reconfigure cycle. Symmetric with the
1171
+ // [lookaheadConfig] docstring above.
1172
+ //
1173
+ // Callbacks also survive — matches iOS, where the subscription is
1174
+ // owned by the JS consumer, not the HybridObject. Consumers call
1175
+ // the returned unsubscribe closure explicitly when they're done.
1176
+ //
1177
+ // Pending-reason channels reset with the player so a subsequent
1178
+ // configure() starts from a clean dispatch state.
1179
+ pendingStateChangeReason = null
1180
+ pendingTrackChangeReason = null
1181
+ lastErrorKey = null
1182
+ retryAttemptsRemaining.clear()
1183
+ if (current === this) current = null
1184
+ }
1185
+
1186
+ /**
1187
+ * Shared tear-down for the player + cache + writer + per-position
1188
+ * state. Tear order matters: prefetcher releases before the cache
1189
+ * so an in-flight `CacheWriter` reading from a released SimpleCache
1190
+ * cannot NPE. SimpleCache enforces a one-instance-per-directory
1191
+ * invariant for the JVM lifetime, so the in-memory cache object is
1192
+ * released here even when the on-disk directory is preserved for
1193
+ * a follow-up `configureInternal` to re-open. When
1194
+ * `notifyTrackChange` is true, fires a synthetic
1195
+ * `(null, -1, QUEUE_REPLACED)` so JS hooks like `useActiveTrack`
1196
+ * clear their snapshot — without it a Player UI rendering
1197
+ * `#${index+1}/${queueLength}` would show `#1/0` after the queue
1198
+ * empties.
1199
+ */
1200
+ private fun releasePlayerAndState(notifyTrackChange: Boolean) {
1201
+ // Cancel any armed sleep timer up front — the engine (and its volume) is
1202
+ // going away, so don't touch it or emit; just stop the tick + reset state.
1203
+ stopSleepTimerTick()
1204
+ sleepTimerCore.clear()
1205
+ sleepTimerFading = false
1206
+ pendingSleepTimerPause = false
1207
+ tearDownEventObservers()
1208
+ lookaheadCacheWriter?.release()
1209
+ lookaheadCacheWriter = null
1210
+ lookaheadCache?.release()
1211
+ lookaheadCache = null
1212
+ // Mark unconfigured so the [player] accessor returns null even
1213
+ // though the service may still be bound (it unbinds at the
1214
+ // bottom of destroyInternal). The PlaybackService releases its
1215
+ // own engine (which owns the ExoPlayer instances) on service
1216
+ // unbind / onDestroy.
1217
+ configured = false
1218
+ val hadTracks = tracks.isNotEmpty()
1219
+ tracks = emptyList()
1220
+ currentTrackIndex = -1
1221
+ currentTrackSource = null
1222
+ currentBufferState = BufferState.EMPTY
1223
+ lastEmittedBufferState = null
1224
+ currentFullyBuffered = false
1225
+ lastEmittedFullyBuffered = null
1226
+ hasStartedPlaying = false
1227
+ wantsToPlay = false
1228
+ lastReportedState = PlayerState.NONE
1229
+ if (notifyTrackChange && hadTracks) {
1230
+ trackChangeListeners.forEach { it(null, -1.0, TrackChangeReason.QUEUE_REPLACED, null) }
1231
+ }
1232
+ refreshNowPlayingFormatForActiveItem()
1233
+ recomputeCapabilities()
1234
+ }
1235
+
1236
+ /**
1237
+ * Run `body` on the main thread, suspending until it completes.
1238
+ * Returns synchronously if the caller is already on main (avoids
1239
+ * an unnecessary Handler round-trip). Mirrors iOS `onMain`.
1240
+ */
1241
+ private suspend fun <T> onMain(body: () -> T): T {
1242
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1243
+ return body()
1244
+ }
1245
+ return suspendCoroutine { cont ->
1246
+ mainHandler.post {
1247
+ try {
1248
+ cont.resume(body())
1249
+ } catch (t: Throwable) {
1250
+ cont.resumeWithException(t)
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Blocking main-thread hop for the synchronous snapshot readers
1258
+ * ([getState], [getVolume], [getPlaybackSpeed], [getCurrentTrackIndex]
1259
+ *.). ExoPlayer's `Player` interface documents that
1260
+ * reads must happen on the application thread — `ExoPlayerImpl`
1261
+ * calls `verifyApplicationThread()` on property reads in Media3
1262
+ * 1.9.3 which throws in debug builds.
1263
+ *
1264
+ * Nitro dispatches sync methods on the JS thread, so without a
1265
+ * blocking hop a JS `getState()` call would crash ExoPlayer.
1266
+ * CountDownLatch is intentional over `postAtFrontOfQueue` — the
1267
+ * wait is expected to be short (single property read), and the
1268
+ * blocking wait guarantees a fresh snapshot rather than a stale
1269
+ * cached value.
1270
+ *
1271
+ * Calling this from main deadlocks by definition — always guard
1272
+ * with the `Looper.myLooper()` fast-path.
1273
+ */
1274
+ private fun <T> syncMain(body: () -> T): T {
1275
+ if (Looper.myLooper() == Looper.getMainLooper()) {
1276
+ return body()
1277
+ }
1278
+ val latch = CountDownLatch(1)
1279
+ var result: Result<T>? = null
1280
+ mainHandler.post {
1281
+ result = runCatching { body() }
1282
+ latch.countDown()
1283
+ }
1284
+ latch.await()
1285
+ return result!!.getOrThrow()
1286
+ }
1287
+
1288
+ // --- Queue: bulk set / clear / get / current-index
1289
+ //
1290
+ // The bulk setter (setQueue) is the Media3 gapless integration
1291
+ // point: `setMediaItems` hands ExoPlayer the entire queue in one
1292
+ // call, which is what enables continuous decoding across item
1293
+ // boundaries. `addToQueue` / `removeFromQueue` / `moveInQueue`
1294
+ // mutate the queue incrementally without disturbing
1295
+ // the current item or the gapless pipeline.
1296
+
1297
+ override fun setQueue(
1298
+ tracks: Array<TrackItem>,
1299
+ startAtIndex: Double?
1300
+ ): Promise<Unit> =
1301
+ Promise.async {
1302
+ // Cast routing: when a remote-player session is active the
1303
+ // queue lands on the receiver via `RemoteMediaClient.queueLoad`
1304
+ // and the local engine stays idle. `routeSetQueue` is suspending
1305
+ // (it awaits the receiver round-trip) so the call happens
1306
+ // outside `onMain` — `ChromecastSession.load` does its own
1307
+ // main-thread hop internally.
1308
+ val routed = com.margelo.nitro.queueplayer.cast.CastTransportRouter
1309
+ .routeSetQueue(
1310
+ tracks = tracks.toList(),
1311
+ startIndex = startAtIndex?.toInt() ?: 0,
1312
+ startPositionMs = 0L,
1313
+ playWhenReady = true,
1314
+ )
1315
+ if (routed) {
1316
+ // Mirror the queue into the local engine (paused) so JS reads
1317
+ // (`getQueue` / `getCurrentTrackIndex`) reflect the user-visible
1318
+ // queue AND the engine is primed at the right item to take over
1319
+ // when cast disconnects. `setQueueInternal` only prepares — it
1320
+ // never starts playback, and transport during cast routes to the
1321
+ // receiver, so the local engine stays paused.
1322
+ val snapshot = tracks.toList()
1323
+ onMain { setQueueInternal(snapshot, startAtIndex?.toInt()) }
1324
+ return@async
1325
+ }
1326
+ onMain {
1327
+ setQueueInternal(tracks.toList(), startAtIndex?.toInt())
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * Hand off current local playback to a newly-connected cast receiver:
1333
+ * load the current queue at the current playhead + play/pause, then
1334
+ * pause the local engine (which keeps its queue, paused, for the
1335
+ * cast-disconnect resume). Suspending — called from the cast bridge's
1336
+ * scope on session activation.
1337
+ */
1338
+ internal suspend fun handoffCurrentPlaybackToCast() {
1339
+ val snapshot = onMain {
1340
+ val engine = serviceBinder?.engine
1341
+ val positionMs = engine?.currentPositionMs ?: 0L
1342
+ val playing = engine?.isPlaying ?: false
1343
+ // Silence local immediately (after the state read) so the device
1344
+ // speaker doesn't overlap the receiver during the load round-trip.
1345
+ engine?.pause()
1346
+ val q = tracks
1347
+ val idx = currentTrackIndex
1348
+ if (idx < 0 || idx >= q.size) null
1349
+ else CastHandoffSnapshot(q.toList(), idx, positionMs, playing)
1350
+ } ?: return
1351
+ // Seed the bridge's resume snapshot from this local pre-handoff state
1352
+ // (captured above) so a disconnect before the receiver reports — the
1353
+ // load round-trip below has not settled yet — still restores local
1354
+ // continuity. Receiver updates override these as they arrive.
1355
+ castEventBridge.seedResumeSnapshot(
1356
+ trackIndex = snapshot.index,
1357
+ positionMs = snapshot.positionMs,
1358
+ )
1359
+ // A failed receiver load (e.g. unreachable LAN address, receiver
1360
+ // rejects the item) must never crash the app — fall back to local
1361
+ // playback, restoring the play-state captured above so the user isn't
1362
+ // left silent after the local engine was paused for the handoff.
1363
+ try {
1364
+ com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetQueue(
1365
+ tracks = snapshot.tracks,
1366
+ startIndex = snapshot.index,
1367
+ startPositionMs = snapshot.positionMs,
1368
+ playWhenReady = snapshot.playing,
1369
+ )
1370
+ } catch (t: Throwable) {
1371
+ Log.w(PLAYER_LOG_TAG, "Cast handoff failed; staying on local playback", t)
1372
+ if (snapshot.playing) onMain { serviceBinder?.engine?.play() }
1373
+ }
1374
+ }
1375
+
1376
+ private data class CastHandoffSnapshot(
1377
+ val tracks: List<TrackItem>,
1378
+ val index: Int,
1379
+ val positionMs: Long,
1380
+ val playing: Boolean,
1381
+ )
1382
+
1383
+ /**
1384
+ * Return local playback to the receiver's last track + position after a
1385
+ * cast session ends. The local engine kept its (paused) queue during
1386
+ * cast, so this seeks it to the handback point and stays PAUSED — the
1387
+ * user resumes locally. This is the conventional cast->local handback;
1388
+ * it never auto-blasts the phone speaker on disconnect. A negative
1389
+ * [trackIndex] (receiver index never resolved) is a no-op.
1390
+ */
1391
+ internal fun resumeLocalAfterCast(trackIndex: Int, positionMs: Long) {
1392
+ onMainSync {
1393
+ val engine = serviceBinder?.engine ?: return@onMainSync
1394
+ // Bound the seek by the engine's prepared timeline, not just the
1395
+ // lib queue: seeking past the window count throws
1396
+ // IllegalSeekPositionException.
1397
+ if (trackIndex < 0 || trackIndex >= engine.allMediaItems.size) return@onMainSync
1398
+ currentTrackIndex = trackIndex
1399
+ engine.seekTo(trackIndex, maxOf(0L, positionMs))
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Replace the entire queue and prime the ExoPlayer for playback.
1405
+ *
1406
+ * Steps:
1407
+ * 1. Stash the authoritative track list on `self.tracks`.
1408
+ * 2. Resolve `startAtIndex`: nil / out-of-range / negative coerce
1409
+ * to 0; clamped to [0, newTracks.size - 1] for populated queues.
1410
+ * Empty queue ignores the parameter (currentTrackIndex stays -1).
1411
+ * 3. Hand ExoPlayer the full MediaItem batch via
1412
+ * `setMediaItems(items, resolvedStart, 0)` so the built-in
1413
+ * gapless pipeline sees every track AND the player's current
1414
+ * item is the consumer's chosen start index.
1415
+ * 4. Call `prepare()` — required after `setMediaItems` for ExoPlayer
1416
+ * to transition out of `STATE_IDLE`. Does NOT start playback
1417
+ * (caller invokes `play()` explicitly).
1418
+ */
1419
+ @VisibleForTesting
1420
+ @MainThread
1421
+ internal fun setQueueInternal(
1422
+ newTracks: List<TrackItem>,
1423
+ startAtIndex: Int? = null
1424
+ ) {
1425
+ val p = player ?: return
1426
+ val items = applyTrackListMetadata(newTracks) ?: return
1427
+ val resolvedStart =
1428
+ if (newTracks.isEmpty()) 0
1429
+ else (startAtIndex ?: 0).coerceIn(0, newTracks.size - 1)
1430
+ if (newTracks.isNotEmpty()) {
1431
+ this.currentTrackIndex = resolvedStart
1432
+ }
1433
+ serviceBinder?.engine?.setMediaItems(
1434
+ items,
1435
+ startIndex = resolvedStart,
1436
+ startPositionMs = 0L
1437
+ )
1438
+ if (items.isNotEmpty()) {
1439
+ p.prepare()
1440
+ }
1441
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1442
+ rescheduleLookahead()
1443
+ recomputeCapabilities()
1444
+ }
1445
+
1446
+ /**
1447
+ * Apply the queue-state side-effects of replacing the track list
1448
+ * WITHOUT setting items on the underlying engine. Shared by
1449
+ * [setQueueInternal] (which then sets engine items + prepares) and
1450
+ * [applyResolvedAssistantQueue] (where Media3's framework sets the
1451
+ * items on the player itself after `onAddMediaItems` /
1452
+ * `onSetMediaItems` returns the resolved list).
1453
+ *
1454
+ * Returns the [MediaItem] list built from [newTracks], or `null`
1455
+ * when the bound context is missing (no service binding yet —
1456
+ * caller bails).
1457
+ */
1458
+ @MainThread
1459
+ private fun applyTrackListMetadata(
1460
+ newTracks: List<TrackItem>
1461
+ ): List<MediaItem>? {
1462
+ val ctx = boundContext ?: return null
1463
+ this.tracks = newTracks
1464
+ this.currentTrackIndex = if (newTracks.isEmpty()) -1 else 0
1465
+ // Stash reason so Media3's REASON_PLAYLIST_CHANGED transition
1466
+ // maps to our QUEUE_REPLACED. Stashing here keeps the reason
1467
+ // channel symmetric with skip / seek.
1468
+ pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
1469
+ // Fresh queue → fresh dedup state. An error on the prior queue's
1470
+ // last item must not suppress an identical coded error on the
1471
+ // new queue's first item.
1472
+ lastErrorKey = null
1473
+ // Reset auto-retry counters: new queue → new budget per index.
1474
+ val attempts = effectiveAutoRetries()
1475
+ retryAttemptsRemaining =
1476
+ (newTracks.indices.associateWith { attempts }).toMutableMap()
1477
+ return MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1478
+ }
1479
+
1480
+ /**
1481
+ * Sync the lib's authoritative queue state to a track list resolved
1482
+ * by an automotive controller (Android Auto / Google Assistant
1483
+ * voice search) before Media3's framework calls `player.setMediaItems`
1484
+ * itself. Reuses [applyTrackListMetadata] so the
1485
+ * `tracks` / `currentTrackIndex` / retry-budget side-effects match
1486
+ * the canonical [setQueueInternal] path; the engine call is
1487
+ * omitted because Media3's framework sets the items on the player
1488
+ * after `onAddMediaItems` / `onSetMediaItems` returns the resolved
1489
+ * list.
1490
+ *
1491
+ * Returns the [MediaItem] list the framework should set on the
1492
+ * player so the caller can hand it back to Media3 in one shape.
1493
+ */
1494
+ @MainThread
1495
+ internal fun applyResolvedAssistantQueue(
1496
+ newTracks: List<TrackItem>
1497
+ ): List<MediaItem> {
1498
+ val items = applyTrackListMetadata(newTracks) ?: return emptyList()
1499
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1500
+ rescheduleLookahead()
1501
+ recomputeCapabilities()
1502
+ return items
1503
+ }
1504
+
1505
+ override fun addToQueue(tracks: Array<TrackItem>, insertBefore: Double?): Promise<Unit> =
1506
+ Promise.async {
1507
+ // Mirror `seekToInternal:890` non-finite guard: NaN/±Infinity
1508
+ // from JS would `.toInt()` to MIN/MAX or 0 silently. When
1509
+ // insertBefore is non-finite, drop it (treat as "append at
1510
+ // end") rather than silently jumping to position 0.
1511
+ val newTracks = tracks.toList()
1512
+ val sanitisedInsert = insertBefore?.takeIf { it.isFinite() }?.toInt()
1513
+ // Mutate the local source-of-truth queue and capture the clamped
1514
+ // insert index against the pre-mutation queue, then mirror the
1515
+ // insert to the receiver. `routeAddToQueue` is suspending (it
1516
+ // awaits the receiver round-trip) so the call happens outside
1517
+ // `onMain`. Mirrors iOS `addToQueue`.
1518
+ val insertAt = onMain { addToQueueInternal(newTracks, sanitisedInsert) }
1519
+ if (newTracks.isNotEmpty() && insertAt >= 0) {
1520
+ com.margelo.nitro.queueplayer.cast.CastTransportRouter
1521
+ .routeAddToQueue(newTracks, insertAt)
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Incrementally extend the queue. Does NOT disturb the currently-
1527
+ * playing item — Media3 `addMediaItems` inserts without re-priming
1528
+ * the pipeline, and we mirror the index math onto the authoritative
1529
+ * `tracks` list + `currentTrackIndex` via [QueueMutationArithmetic].
1530
+ *
1531
+ * Does NOT stash [pendingTrackChangeReason]. Media3 fires
1532
+ * `REASON_PLAYLIST_CHANGED` on the subsequent transition, which
1533
+ * [handleMediaItemTransition] routes to the default
1534
+ * [TrackChangeReason.QUEUE_REPLACED]. addToQueue / removeFromQueue
1535
+ * / moveInQueue all fall through to that same default by design
1536
+ * — the PlayerState spec does not yet carry a finer-grained
1537
+ * "queue mutated incrementally" reason.
1538
+ */
1539
+ @VisibleForTesting
1540
+ internal fun addToQueueInternal(newTracks: List<TrackItem>, insertBefore: Int?): Int {
1541
+ // Empty input → no track-list shift, no lookahead window shift,
1542
+ // no reschedule needed. The trailing `rescheduleLookahead()` is
1543
+ // intentionally not run on this branch. `-1` signals the caller's
1544
+ // cast mirror to skip the receiver insert.
1545
+ if (newTracks.isEmpty()) return -1
1546
+ val p = player ?: return -1
1547
+ val ctx = boundContext ?: return -1
1548
+
1549
+ val at = QueueMutationArithmetic.clampInsertBefore(insertBefore, tracks.size)
1550
+ this.tracks = tracks.toMutableList().apply { addAll(at, newTracks) }
1551
+ this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterAdd(
1552
+ currentIndex = currentTrackIndex,
1553
+ insertAt = at,
1554
+ count = newTracks.size
1555
+ )
1556
+
1557
+ // Index-keyed retry-budget map: insertion at `at` shifts every
1558
+ // existing key >= at up by `newTracks.size`. Snapshot via
1559
+ // `filterKeys` so mutation of the live map is safe regardless of
1560
+ // iteration order; `toSortedMap` is incidental cleanliness, not
1561
+ // load-bearing for correctness.
1562
+ val attempts = effectiveAutoRetries()
1563
+ val shift = newTracks.size
1564
+ val shifted = retryAttemptsRemaining
1565
+ .filterKeys { it >= at }
1566
+ .toSortedMap(compareByDescending { it })
1567
+ for ((oldIdx, count) in shifted) {
1568
+ retryAttemptsRemaining.remove(oldIdx)
1569
+ retryAttemptsRemaining[oldIdx + shift] = count
1570
+ }
1571
+ for (newIdx in at until at + shift) {
1572
+ retryAttemptsRemaining[newIdx] = attempts
1573
+ }
1574
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1575
+ serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
1576
+ // Diverges from iOS: add-into-empty-queue on iOS leaves the
1577
+ // AVQueuePlayer empty until the consumer calls skipToIndex(0).
1578
+ // Android primes the pipeline here so a subsequent
1579
+ // skipToIndex(0) + play() starts instantly rather than going
1580
+ // through an IDLE → prepare → BUFFERING transition at play
1581
+ // time. Deliberate improvement — cur stays at -1 so the
1582
+ // documented "empty-queue add does not auto-start playback"
1583
+ // contract is still intact.
1584
+ if (p.playbackState == Player.STATE_IDLE && p.mediaItemCount > 0) {
1585
+ p.prepare()
1586
+ }
1587
+ // Re-evaluate against the post-add `tracks` list — adding even
1588
+ // one remote URL into a previously-local queue must upgrade the
1589
+ // wake-mode to NETWORK so the WiFi wake lock is held while
1590
+ // streaming.
1591
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1592
+ rescheduleLookahead()
1593
+ recomputeCapabilities()
1594
+ return at
1595
+ }
1596
+
1597
+ override fun removeFromQueue(indices: DoubleArray): Promise<Unit> =
1598
+ Promise.async {
1599
+ // Mutate the local source-of-truth queue and capture the
1600
+ // sanitised-AND-pin-filtered indices the body actually removed,
1601
+ // then mirror that exact set to the receiver. `routeRemoveFromQueue`
1602
+ // is suspending so it runs outside `onMain`. Mirrors iOS
1603
+ // `removeFromQueue`.
1604
+ val rawIndices = indices.map { it.toInt() }
1605
+ val castIndices = onMain { removeFromQueueInternal(rawIndices) }
1606
+ if (castIndices.isNotEmpty()) {
1607
+ com.margelo.nitro.queueplayer.cast.CastTransportRouter
1608
+ .routeRemoveFromQueue(castIndices)
1609
+ }
1610
+ }
1611
+
1612
+ @VisibleForTesting
1613
+ internal fun removeFromQueueInternal(rawIndices: List<Int>): List<Int> {
1614
+ val p = player ?: return emptyList()
1615
+ val trackCountBefore = tracks.size
1616
+ // The currently-playing track is pinned: it cannot be removed via
1617
+ // removeFromQueue (a consumer that wants it gone skips off it first,
1618
+ // then removes it). Drop the current index from the set — removing
1619
+ // only the current track is a no-op; a mixed request drops the other
1620
+ // tracks and keeps the current one. Matches iOS removeFromQueueBody.
1621
+ val sanitised = QueueMutationArithmetic.sanitiseRemoveIndices(
1622
+ rawIndices, trackCountBefore
1623
+ ).filter { it != currentTrackIndex }
1624
+ // Sanitised-empty (all inputs out-of-range / negative dups
1625
+ // collapsing to nothing, or only the pinned current index) → no
1626
+ // track-list change, no lookahead window shift. Skip the reschedule.
1627
+ if (sanitised.isEmpty()) return emptyList()
1628
+
1629
+ val oldCur = currentTrackIndex
1630
+ val descending = sanitised.asReversed()
1631
+
1632
+ // Apply to the authoritative track list first. Descending order
1633
+ // so each removal index stays valid as the list shrinks.
1634
+ val newTracks = tracks.toMutableList()
1635
+ for (i in descending) {
1636
+ newTracks.removeAt(i)
1637
+ }
1638
+ this.tracks = newTracks
1639
+ this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterRemove(
1640
+ currentIndex = oldCur,
1641
+ sanitisedIndices = sanitised,
1642
+ trackCountBefore = trackCountBefore
1643
+ )
1644
+
1645
+ // Forward removals to the engine. The engine sorts descending
1646
+ // and walks each index individually so positions before each
1647
+ // removal stay stable.
1648
+ //
1649
+ // Ordering note: Kotlin state writes land BEFORE the engine
1650
+ // removal, so an `onMediaItemTransition` listener fired during
1651
+ // an auto-advance triggered by removing the current item sees
1652
+ // the post-remove `currentTrackIndex`. A listener that needs
1653
+ // the pre-mutation reason must cache `oldCur` itself.
1654
+ val inBoundsDescending = descending.filter { it < p.mediaItemCount }
1655
+ if (inBoundsDescending.isNotEmpty()) {
1656
+ serviceBinder?.engine?.removeMediaItems(inBoundsDescending.toIntArray())
1657
+ }
1658
+ // Index-keyed retry-budget map: removals shift remaining keys
1659
+ // down. We could walk the survivors with an offset count to
1660
+ // preserve in-flight budgets for unaffected items, but the
1661
+ // simpler clear+repopulate is intentional:
1662
+ //
1663
+ // - **Structural-reset semantics**: removeFromQueue is a
1664
+ // coarse mutation. Resetting budgets aligns with how
1665
+ // setQueue / clearQueue / shuffleQueue all behave — every
1666
+ // queue mutation that's not a single-item move is a fresh
1667
+ // slate.
1668
+ // - **Trade-off**: a failing item that's NOT the one being
1669
+ // removed gets its budget reset. A consumer hitting a flaky
1670
+ // item, mid-retry, who removes a different item, gives the
1671
+ // flaky item another full budget. Generous-not-stingy is the
1672
+ // right default for a UX where the user's action signals
1673
+ // "try again with fresh expectations".
1674
+ // - **Why not preserve via remap**: requires walking the map
1675
+ // AND the sanitised-indices in lockstep with offset
1676
+ // accounting. The clear-and-repopulate is intentional.
1677
+ val attempts = effectiveAutoRetries()
1678
+ retryAttemptsRemaining.clear()
1679
+ for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1680
+ // Mirror the wake-mode hook on `setQueueInternal` /
1681
+ // `addToQueueInternal`: removing the last remote URL out of a
1682
+ // mixed queue must downgrade to LOCAL so the WiFi wake lock
1683
+ // doesn't stay held for the rest of the session.
1684
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1685
+ rescheduleLookahead()
1686
+ recomputeCapabilities()
1687
+ return sanitised
1688
+ }
1689
+
1690
+ override fun moveInQueue(fromIndex: Double, toIndex: Double): Promise<Unit> =
1691
+ Promise.async {
1692
+ // Mutate the local source-of-truth queue and capture the
1693
+ // validated (from, to) only when the body actually moved, then
1694
+ // mirror the reorder to the receiver. `routeMoveInQueue` is
1695
+ // suspending so it runs outside `onMain`. Mirrors iOS `moveInQueue`.
1696
+ val move = onMain {
1697
+ // Mirror `seekToInternal:890` non-finite guard.
1698
+ if (!fromIndex.isFinite() || !toIndex.isFinite()) return@onMain null
1699
+ val from = fromIndex.toInt()
1700
+ val to = toIndex.toInt()
1701
+ if (moveInQueueInternal(from, to)) from to to else null
1702
+ }
1703
+ if (move != null) {
1704
+ com.margelo.nitro.queueplayer.cast.CastTransportRouter
1705
+ .routeMoveInQueue(move.first, move.second)
1706
+ }
1707
+ }
1708
+
1709
+ /**
1710
+ * Move a single track to a new position. The currently-playing track
1711
+ * is pinned: moving it (`from == currentTrackIndex`) is a no-op on
1712
+ * both platforms — only the tracks around the current one reorder.
1713
+ * This keeps the contract identical cross-platform (iOS's
1714
+ * AVQueuePlayer cannot reorder the play-head item without restarting
1715
+ * it, so both platforms simply disallow it).
1716
+ */
1717
+ @VisibleForTesting
1718
+ internal fun moveInQueueInternal(from: Int, to: Int): Boolean {
1719
+ val p = player ?: return false
1720
+ if (from < 0 || from >= tracks.size) return false
1721
+ if (to < 0 || to >= tracks.size) return false
1722
+ // from == to is a no-op move — track list unchanged, no
1723
+ // lookahead window shift. Skip the trailing reschedule.
1724
+ if (from == to) return false
1725
+ // The currently-playing track is pinned; moving it is a no-op.
1726
+ // Matches iOS moveInQueueBody.
1727
+ if (from == currentTrackIndex) return false
1728
+
1729
+ val mutable = tracks.toMutableList()
1730
+ val moved = mutable.removeAt(from)
1731
+ mutable.add(to, moved)
1732
+ this.tracks = mutable
1733
+
1734
+ this.currentTrackIndex = QueueMutationArithmetic.currentIndexAfterMove(
1735
+ currentIndex = currentTrackIndex,
1736
+ fromIndex = from,
1737
+ toIndex = to
1738
+ )
1739
+
1740
+ if (from < p.mediaItemCount && to < p.mediaItemCount) {
1741
+ serviceBinder?.engine?.moveMediaItem(from, to)
1742
+ }
1743
+ // Index-keyed retry-budget map: a move shuffles indices
1744
+ // arbitrarily. Cheapest correct path is to clear + repopulate
1745
+ // (same trade-off as removeFromQueueInternal — structural reset
1746
+ // semantics for queue mutations).
1747
+ val attempts = effectiveAutoRetries()
1748
+ retryAttemptsRemaining.clear()
1749
+ for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1750
+ rescheduleLookahead()
1751
+ recomputeCapabilities()
1752
+ return true
1753
+ }
1754
+
1755
+ override fun getQueue(): Array<TrackItem> =
1756
+ tracks.toTypedArray()
1757
+
1758
+ override fun clearQueue(): Promise<Unit> =
1759
+ Promise.async {
1760
+ // Clear the local source-of-truth queue, then mirror the clear to
1761
+ // the receiver (removes every receiver queue item). `routeClearQueue`
1762
+ // is suspending so it runs outside `onMain`. Mirrors iOS `clearQueue`.
1763
+ onMain { clearQueueInternal() }
1764
+ com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeClearQueue()
1765
+ }
1766
+
1767
+ @VisibleForTesting
1768
+ internal fun clearQueueInternal() {
1769
+ val p = player ?: return
1770
+ // Reset authoritative state BEFORE the ExoPlayer mutation.
1771
+ // `clearMediaItems()` fires `onMediaItemTransition(null,
1772
+ // REASON_PLAYLIST_CHANGED)` synchronously, which routes through
1773
+ // `handleMediaItemTransition` → `trackChangeListeners.forEach` →
1774
+ // reads `tracks[currentTrackIndex]`. With the old order
1775
+ // (clear → reset), the listener would read the pre-clear state
1776
+ // and fire onTrackChange with (oldTrack, oldIndex, QUEUE_REPLACED)
1777
+ // instead of (null, -1, QUEUE_REPLACED), leaving JS hooks /
1778
+ // useActiveTrack stuck on stale values. setQueueInternal already
1779
+ // follows this invariant — same rule applies here.
1780
+ pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
1781
+ // Reset error-dedup so a subsequent queue's identical-coded error
1782
+ // isn't suppressed by a stale entry from the cleared queue.
1783
+ lastErrorKey = null
1784
+ retryAttemptsRemaining.clear()
1785
+ tracks = emptyList()
1786
+ currentTrackIndex = -1
1787
+ // Route through the engine surface (not directly through the
1788
+ // leading leg) so CrossfadeEngine can also tear down the
1789
+ // standby leg's preroll'd items + cancel any in-flight fade.
1790
+ // setMediaItems(emptyList(), 0, 0L) is the canonical "drain"
1791
+ // signal both engine impls handle correctly.
1792
+ serviceBinder?.engine?.setMediaItems(emptyList(), startIndex = 0, startPositionMs = 0L)
1793
+ // Drop the WiFi wake lock if the prior queue was remote.
1794
+ // Distinguished from `pickWakeMode(emptyList())` (which returns
1795
+ // NETWORK conservatively because we don't know what's coming
1796
+ // next) — post-clear there's nothing playing AND nothing
1797
+ // scheduled, so LOCAL is correct. The next setQueue picks the
1798
+ // mode based on the new queue's URI mix. The engine.setMediaItems
1799
+ // call above already drained both legs; routing through the engine
1800
+ // applies the wake-mode write to BOTH crossfade legs, not just the
1801
+ // leading one.
1802
+ serviceBinder?.engine?.setWakeMode(C.WAKE_MODE_LOCAL)
1803
+ // Drop any cached now-playing format + emit null so JS-side
1804
+ // consumers of `onNowPlayingFormatChange` see the queue-cleared
1805
+ // state without waiting for ExoPlayer's `currentMediaItem` to
1806
+ // settle (it can briefly retain the last item post-clearMediaItems).
1807
+ refreshNowPlayingFormatForActiveItem()
1808
+ rescheduleLookahead()
1809
+ recomputeCapabilities()
1810
+ // Queue empty → settle buffer state + fully-buffered to their empty
1811
+ // defaults and emit the transition. The engine reaching STATE_IDLE above
1812
+ // recomputes buffer state too, but reset the intent flags + recompute
1813
+ // explicitly so an immediate re-queue of the same first item (no
1814
+ // track-change fire) starts as `buffering`, not `stalled`.
1815
+ hasStartedPlaying = false
1816
+ wantsToPlay = false
1817
+ recomputeBufferState()
1818
+ recomputeFullyBuffered()
1819
+ }
1820
+
1821
+ // --- Transport: pass-through to ExoPlayer.
1822
+ // No event wiring here — state transitions flow through the player
1823
+ // listener + polling tick.
1824
+
1825
+ override fun play(): Promise<Unit> =
1826
+ Promise.async {
1827
+ onMain {
1828
+ // Cast routing: a live remote-player session takes the
1829
+ // transport command; otherwise fall through to the local
1830
+ // engine. Same shape at every transport entry point — see
1831
+ // `CastTransportRouter` for the early-return contract.
1832
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1833
+ // Stuck-recovery: if the user re-taps play after
1834
+ // an exhausted-retries error, give one more shot. Reset
1835
+ // counter to 1 (single shot, not full autoRetries — each
1836
+ // user tap = one attempt for predictable UX) and re-prepare.
1837
+ // Mirrors iOS `play()` stuck-recovery.
1838
+ if (lastReportedState == PlayerState.ERROR &&
1839
+ currentTrackIndex >= 0 &&
1840
+ (retryAttemptsRemaining[currentTrackIndex] ?: 0) == 0) {
1841
+ retryAttemptsRemaining[currentTrackIndex] = 1
1842
+ lastErrorKey = null
1843
+ player?.prepare()
1844
+ }
1845
+ // Stash USER on the pending-reason channel so the
1846
+ // subsequent Player.Listener fire attributes the transition
1847
+ // correctly.
1848
+ pendingStateChangeReason = StateChangeReason.USER
1849
+ wantsToPlay = true
1850
+ serviceBinder?.engine?.play()
1851
+ recomputeBufferState()
1852
+ }
1853
+ }
1854
+
1855
+ override fun retry(): Promise<Unit> =
1856
+ Promise.async {
1857
+ onMain {
1858
+ // During cast the receiver owns playback — forward a play so a failed
1859
+ // receiver item re-attempts.
1860
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return@onMain
1861
+ // Local recovery: only meaningful for a track that errored after its
1862
+ // automatic retries were exhausted. Give it one fresh attempt — reset
1863
+ // the per-track retry budget, then rebuild the failed item via
1864
+ // retryFailedItem, which discards the wedged DataSource (a bare
1865
+ // prepare() would re-use it), restores the pre-failure position, and
1866
+ // resumes playing.
1867
+ if (lastReportedState != PlayerState.ERROR || currentTrackIndex < 0) return@onMain
1868
+ retryAttemptsRemaining[currentTrackIndex] = 1
1869
+ lastErrorKey = null
1870
+ pendingStateChangeReason = StateChangeReason.USER
1871
+ wantsToPlay = true
1872
+ retryFailedItem(currentTrackIndex)
1873
+ recomputeBufferState()
1874
+ }
1875
+ }
1876
+
1877
+ override fun pause(): Promise<Unit> =
1878
+ Promise.async {
1879
+ onMain {
1880
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePause()) return@onMain
1881
+ pendingStateChangeReason = StateChangeReason.USER
1882
+ wantsToPlay = false
1883
+ serviceBinder?.engine?.pause()
1884
+ recomputeBufferState()
1885
+ }
1886
+ }
1887
+
1888
+ /**
1889
+ * "Stop" = pause + seek to start of current item. Does NOT clear
1890
+ * the queue — consumers wanting a full reset call clearQueue()
1891
+ *. iOS parity.
1892
+ */
1893
+ override fun stop(): Promise<Unit> =
1894
+ Promise.async {
1895
+ onMain {
1896
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeStop()) return@onMain
1897
+ val engine = serviceBinder?.engine ?: return@onMain
1898
+ pendingStateChangeReason = StateChangeReason.USER
1899
+ wantsToPlay = false
1900
+ hasStartedPlaying = false
1901
+ engine.pause()
1902
+ // Skip the seek when no track is current (queue empty OR
1903
+ // configure transient where engine items exist but the
1904
+ // canonical model hasn't pinned an index yet); jumping to
1905
+ // index 0 in that window would force the head off whatever
1906
+ // the engine's stale current item happens to be.
1907
+ if (currentTrackIndex >= 0) {
1908
+ engine.seekTo(currentTrackIndex, 0L)
1909
+ }
1910
+ recomputeBufferState()
1911
+ }
1912
+ }
1913
+
1914
+ /**
1915
+ * Seek within the current track. [position] is in seconds; negative
1916
+ * values clamp to 0, values past the duration clamp to duration.
1917
+ * ExoPlayer takes milliseconds so we convert here.
1918
+ */
1919
+ override fun seekTo(position: Double): Promise<Unit> =
1920
+ Promise.async {
1921
+ onMain {
1922
+ val positionMs = (position.coerceAtLeast(0.0) * 1000.0).toLong()
1923
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSeekTo(positionMs)) return@onMain
1924
+ seekToInternal(position)
1925
+ }
1926
+ }
1927
+
1928
+ @VisibleForTesting
1929
+ internal fun seekToInternal(position: Double) {
1930
+ val p = player ?: return
1931
+ // Sanitise non-finite JS inputs (NaN / ±Infinity). Without this
1932
+ // `(Infinity * 1000.0).toLong()` resolves to Long.MAX_VALUE which
1933
+ // ExoPlayer interprets as "seek past end"; NaN silently floors to
1934
+ // 0 which is benign but misleading. Treat both as invalid and
1935
+ // no-op rather than letting the clamp fall through.
1936
+ //
1937
+ // No `rescheduleLookahead()` call anywhere in this function:
1938
+ // intra-track seek does NOT shift the lookahead window
1939
+ // (`tracks` + `currentTrackIndex` are unchanged), so the
1940
+ // prefetcher's existing chain stays valid. The omission is
1941
+ // deliberate, not a forgotten case.
1942
+ if (!position.isFinite()) return
1943
+ // Protect against ExoPlayer's "seek on an unseekable track silently resets
1944
+ // to the start" behaviour: no-op the seek once the item is loaded
1945
+ // (STATE_READY / ENDED) AND confirmed unseekable (a live source, or a VBR
1946
+ // MP3 with no seek index). Gating on loaded state means an early seek on an
1947
+ // eventually-seekable track — whose window isn't populated yet — still
1948
+ // lands. Seekable tracks (incl. CBR MP3 / ADTS / AMR via constant-bitrate
1949
+ // seeking) seek freely and re-buffer from the target.
1950
+ val loaded = p.playbackState == Player.STATE_READY || p.playbackState == Player.STATE_ENDED
1951
+ if (loaded && !p.isCurrentMediaItemSeekable) return
1952
+ // Clamp against the ACTIVE leg's duration. During a crossfade the
1953
+ // active track is the incoming (standby) leg, so reading the
1954
+ // leading player's `duration` would clamp against the wrong track.
1955
+ // `currentDurationMs` retargets to the active leg mid-fade and
1956
+ // equals `player.duration` on the gapless engine.
1957
+ val durationMs = serviceBinder?.engine?.currentDurationMs ?: p.duration
1958
+ val clampedSeconds = if (position < 0.0) {
1959
+ 0.0
1960
+ } else {
1961
+ // `currentDurationMs` normalizes an unknown duration to 0; the
1962
+ // `?: p.duration` fallback reports TIME_UNSET. Treat BOTH as "duration
1963
+ // not known yet" and pass the seek through unclamped — clamping an
1964
+ // early-load or duration-less/live track to a 0 duration would snap
1965
+ // every forward seek back to position 0.
1966
+ if (durationMs == C.TIME_UNSET || durationMs <= 0L) {
1967
+ position
1968
+ } else {
1969
+ val durationSeconds = durationMs / MS_PER_SECOND
1970
+ if (position > durationSeconds) durationSeconds else position
1971
+ }
1972
+ }
1973
+ val finalSeconds = clampToBufferedIfEnabled(clampedSeconds)
1974
+ // currentTrackIndex == -1 only when the timeline is empty;
1975
+ // Media3's seekTo short-circuits cleanly in that case so the
1976
+ // coerce here is benign rather than load-bearing.
1977
+ serviceBinder?.engine?.seekTo(
1978
+ currentTrackIndex.coerceAtLeast(0),
1979
+ (finalSeconds * MS_PER_SECOND).toLong(),
1980
+ )
1981
+ // Move the milestone baseline to the seek target without emitting, so
1982
+ // thresholds the seek jumps over are suppressed (they weren't played).
1983
+ milestoneTracker.seek(
1984
+ effectiveMilestoneDuration(serviceBinder?.engine?.currentDurationMs ?: C.TIME_UNSET),
1985
+ finalSeconds,
1986
+ )
1987
+ }
1988
+
1989
+ // Cap `targetSeconds` at the current buffered extent when `clampSeekToBuffered`
1990
+ // is enabled and the target is past what has buffered. A local or fully-
1991
+ // buffered track reports `bufferedPosition >= duration >= target`, so nothing
1992
+ // is clamped and a seek reaches the end freely; a partly-buffered stream caps
1993
+ // at the downloaded edge so a scrub doesn't overshoot into a re-buffer. Reads
1994
+ // the live buffered position (not a tracked mark) so a seek-back that evicted
1995
+ // the ahead-buffer clamps to what's actually there.
1996
+ private fun clampToBufferedIfEnabled(targetSeconds: Double): Double {
1997
+ if (config.clampSeekToBuffered != true) return targetSeconds
1998
+ val bufferedMs = player?.bufferedPosition ?: return targetSeconds
1999
+ if (bufferedMs == C.TIME_UNSET || bufferedMs <= 0L) return targetSeconds
2000
+ val bufferedSeconds = bufferedMs / MS_PER_SECOND
2001
+ return if (targetSeconds > bufferedSeconds) bufferedSeconds else targetSeconds
2002
+ }
2003
+
2004
+ // Map the live player's Media3 state + lib intent flags to a discrete
2005
+ // [BufferState]; the translation itself lives in [BufferStateTranslator].
2006
+ private fun computeBufferState(): BufferState {
2007
+ val p = player ?: return BufferState.EMPTY
2008
+ return BufferStateTranslator.translate(p.playbackState, hasStartedPlaying, wantsToPlay)
2009
+ }
2010
+
2011
+ // Recompute the current buffer state and fire `onBufferStateChange` on an
2012
+ // actual change. Runs on the engine state-change delegate and the transport
2013
+ // methods — every input (playbackState, playWhenReady, the intent flags)
2014
+ // changes through one of those, so (unlike iOS, whose crossfade engine has no
2015
+ // per-item buffer KVO and leans on the progress tick) the tick is
2016
+ // deliberately NOT a recompute site here. Main-thread confined (Media3 reads).
2017
+ internal fun recomputeBufferState() {
2018
+ val computed = computeBufferState()
2019
+ currentBufferState = computed
2020
+ if (computed != lastEmittedBufferState) {
2021
+ lastEmittedBufferState = computed
2022
+ bufferStateListeners.forEach { it(computed) }
2023
+ }
2024
+ }
2025
+
2026
+ // The whole track has downloaded when the buffered position reaches the
2027
+ // duration. Falls back to the consumer-supplied track duration (the same
2028
+ // fallback milestones use) when the player can't report one — e.g. a
2029
+ // transcoded stream. Constant-bitrate seeking also supplies an estimated
2030
+ // duration for CBR MP3, so fully-buffered is approximate for those. A live /
2031
+ // dynamic window never completes.
2032
+ private fun computeFullyBuffered(): Boolean {
2033
+ val p = player ?: return false
2034
+ if (p.isCurrentMediaItemLive || p.isCurrentMediaItemDynamic) return false
2035
+ // A local file or a fully-cached source is entirely on disk — fully
2036
+ // available regardless of the player's windowed in-memory buffer (ExoPlayer
2037
+ // only holds ~tens of seconds ahead, so bufferedPosition never reaches
2038
+ // duration for a long file even when nothing is left to fetch).
2039
+ val source = currentTrackSource
2040
+ if (source == TrackSource.LOCAL || source == TrackSource.CACHED) return true
2041
+ val durSeconds = effectiveMilestoneDuration(p.duration)
2042
+ if (durSeconds <= 0.0) return false
2043
+ val bufferedMs = p.bufferedPosition
2044
+ if (bufferedMs == C.TIME_UNSET) return false
2045
+ return bufferedMs / MS_PER_SECOND >= durSeconds - FULLY_BUFFERED_EPSILON_MS / MS_PER_SECOND
2046
+ }
2047
+
2048
+ // Recompute fully-buffered and fire `onFullyBufferedChange` on a change. Runs
2049
+ // on the progress tick (bufferedPosition grows continuously toward duration)
2050
+ // and at track change. Main-thread confined (Media3 reads).
2051
+ internal fun recomputeFullyBuffered() {
2052
+ val computed = computeFullyBuffered()
2053
+ currentFullyBuffered = computed
2054
+ if (computed != lastEmittedFullyBuffered) {
2055
+ lastEmittedFullyBuffered = computed
2056
+ fullyBufferedListeners.forEach { it(computed) }
2057
+ }
2058
+ }
2059
+
2060
+ // --- Skip
2061
+ //
2062
+ // Android ExoPlayer is natively bidirectional — `seekTo(mediaItemIndex,
2063
+ // positionMs)` moves to any item in the currently-set queue without
2064
+ // the iOS-side AVQueuePlayer-forward-only-rebuild ceremony. The
2065
+ // skip methods delegate the index math to [QueueSkipArithmetic]
2066
+ // (pure port of the iOS sibling) and then call
2067
+ // `player.seekTo(newIndex, 0L)`.
2068
+ //
2069
+ // When `tracks` is empty and `currentTrackIndex` stays at -1, the
2070
+ // arithmetic returns null and each skip method silently no-ops.
2071
+
2072
+ override fun skipToIndex(index: Double): Promise<Unit> =
2073
+ Promise.async {
2074
+ onMain {
2075
+ // Mirror `seekToInternal:890` non-finite guard. Without this
2076
+ // `Double.NaN.toInt()` returns 0 — `skipToIndex(NaN)` would
2077
+ // silently jump to track 0 instead of no-op'ing. iOS uses
2078
+ // `InputGuards.validQueueIndex` for the same guarantee.
2079
+ if (!index.isFinite()) return@onMain
2080
+ val target = index.toInt()
2081
+ if (target < 0 || target >= tracks.size) return@onMain
2082
+ // During cast, jump the receiver to the target index; its status
2083
+ // echo drives currentTrackIndex + the metadata/active-track update
2084
+ // back through the bridge. The local engine stays paused, so a
2085
+ // local seek here would desync from the receiver. `routeSkipToIndex`
2086
+ // is synchronous, so it's safe inside `onMain`. Mirrors iOS
2087
+ // `skipToIndex`.
2088
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(target)) {
2089
+ return@onMain
2090
+ }
2091
+ skipToIndexInternal(target)
2092
+ }
2093
+ }
2094
+
2095
+ /**
2096
+ * Explicit consumer-target skip. Honours the index in every
2097
+ * repeat mode, including `QUEUE` (it never wraps — the target is
2098
+ * absolute). Out-of-range / empty-queue inputs silently no-op
2099
+ * (iOS parity).
2100
+ */
2101
+ @VisibleForTesting
2102
+ internal fun skipToIndexInternal(target: Int) {
2103
+ val p = player ?: return
2104
+ if (target < 0 || target >= tracks.size) return
2105
+ currentTrackIndex = target
2106
+ pendingTrackChangeReason = TrackChangeReason.USER_SKIP_TO_INDEX
2107
+ // Guard against IllegalSeekPositionException: `seekTo(index, 0L)`
2108
+ // throws when `index` is outside `mediaItemCount`. When the lib
2109
+ // is between `clearQueue` and the next `setMediaItems`, the
2110
+ // player's timeline is empty and we only update the logical
2111
+ // index — a future setQueue re-issues the seek as it sets the
2112
+ // media items.
2113
+ if (target < p.mediaItemCount) {
2114
+ serviceBinder?.engine?.seekTo(target, 0L)
2115
+ }
2116
+ rescheduleLookahead()
2117
+ recomputeCapabilities()
2118
+ }
2119
+
2120
+ override fun skipToNext(): Promise<Unit> =
2121
+ Promise.async {
2122
+ onMain {
2123
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return@onMain
2124
+ skipToNextInternal()
2125
+ }
2126
+ }
2127
+
2128
+ @VisibleForTesting
2129
+ internal fun skipToNextInternal() {
2130
+ val p = player ?: return
2131
+ val next = computeNextIndex() ?: return
2132
+ currentTrackIndex = next
2133
+ pendingTrackChangeReason = TrackChangeReason.USER_SKIP_NEXT
2134
+ if (next < p.mediaItemCount) {
2135
+ serviceBinder?.engine?.seekTo(next, 0L)
2136
+ }
2137
+ rescheduleLookahead()
2138
+ recomputeCapabilities()
2139
+ }
2140
+
2141
+ override fun skipToPrevious(): Promise<Unit> =
2142
+ Promise.async {
2143
+ onMain {
2144
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return@onMain
2145
+ skipToPreviousInternal()
2146
+ }
2147
+ }
2148
+
2149
+ /**
2150
+ * Standard skip-previous: go to the previous track if there is one
2151
+ * (per the arithmetic helper; OFF/TRACK don't wrap, QUEUE wraps).
2152
+ * Under TRACK this navigates like OFF and repeat-one persists. Apps
2153
+ * that want the Apple-Music-style "tap Previous restarts when
2154
+ * position > 3s" UX implement that in their button handler — the
2155
+ * lib provides the primitive (skip if there's a track to skip to),
2156
+ * not the policy.
2157
+ */
2158
+ @VisibleForTesting
2159
+ internal fun skipToPreviousInternal() {
2160
+ val p = player ?: return
2161
+ val prev = computePreviousIndex() ?: return
2162
+ currentTrackIndex = prev
2163
+ pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
2164
+ if (prev < p.mediaItemCount) {
2165
+ serviceBinder?.engine?.seekTo(prev, 0L)
2166
+ }
2167
+ rescheduleLookahead()
2168
+ recomputeCapabilities()
2169
+ }
2170
+
2171
+ /**
2172
+ * Next-track index. Mirrors iOS `TrackPlayer.computeNextIndex`:
2173
+ * a thin adapter that translates the Nitrogen [RepeatMode] enum
2174
+ * into [QueueSkipRepeatMode] and delegates to [QueueSkipArithmetic].
2175
+ * `internal` so the test harness can exercise the adapter and
2176
+ * `repeatModeState`-read together.
2177
+ */
2178
+ @VisibleForTesting
2179
+ internal fun computeNextIndex(): Int? =
2180
+ QueueSkipArithmetic.computeNext(
2181
+ trackCount = tracks.size,
2182
+ currentIndex = currentTrackIndex,
2183
+ repeatMode = repeatModeAsSkipMode()
2184
+ )
2185
+
2186
+ @VisibleForTesting
2187
+ internal fun computePreviousIndex(): Int? =
2188
+ QueueSkipArithmetic.computePrevious(
2189
+ trackCount = tracks.size,
2190
+ currentIndex = currentTrackIndex,
2191
+ repeatMode = repeatModeAsSkipMode()
2192
+ )
2193
+
2194
+ private fun repeatModeAsSkipMode(): QueueSkipRepeatMode = when (repeatModeState) {
2195
+ RepeatMode.OFF -> QueueSkipRepeatMode.OFF
2196
+ RepeatMode.TRACK -> QueueSkipRepeatMode.TRACK
2197
+ RepeatMode.QUEUE -> QueueSkipRepeatMode.QUEUE
2198
+ }
2199
+
2200
+ /**
2201
+ * Recompute [cachedSkipCapability] from
2202
+ * authoritative state and fire [onSkipCapabilityChange] if either
2203
+ * flipped.
2204
+ *
2205
+ * **Threading invariant:** must be called on main. Reads `tracks`
2206
+ * / `currentTrackIndex` / `repeatModeState`, all of which mutate
2207
+ * only on main, so the read is race-free; the listener fire
2208
+ * iterates a snapshot under [ListenerRegistry]'s internal lock
2209
+ * so subscriber add/remove during the fire round is safe.
2210
+ *
2211
+ * **Dedup:** the listener fires only when the (prev, next) tuple
2212
+ * actually changes. Hook points: setQueueInternal,
2213
+ * addToQueueInternal, removeFromQueueInternal, moveInQueueInternal,
2214
+ * clearQueueInternal, skipToIndexInternal / skipToNextInternal /
2215
+ * skipToPreviousInternal (all index writes), setRepeatModeInternal,
2216
+ * shuffleQueueInternal, handleMediaItemTransition (covers Media3
2217
+ * REASON_AUTO + REASON_REPEAT auto-advance), destroyInternal.
2218
+ */
2219
+ @MainThread
2220
+ internal fun recomputeCapabilities() {
2221
+ val mode = repeatModeAsSkipMode()
2222
+ val count = tracks.size
2223
+ val cur = currentTrackIndex
2224
+ val newPrev = QueueSkipArithmetic.canSkipPrevious(count, cur, mode)
2225
+ val newNext = QueueSkipArithmetic.canSkipNext(count, cur, mode)
2226
+ val current = cachedSkipCapability
2227
+ val capChanged =
2228
+ newPrev != current.canSkipPrevious || newNext != current.canSkipNext
2229
+ val hasMediaItems = count > 0
2230
+ val emptyStateChanged = hasMediaItems != lastHasMediaItemsReported
2231
+ if (!capChanged && !emptyStateChanged) return
2232
+ lastHasMediaItemsReported = hasMediaItems
2233
+ val snapshot = SkipCapability(
2234
+ canSkipPrevious = newPrev, canSkipNext = newNext)
2235
+ cachedSkipCapability = snapshot
2236
+ if (capChanged) skipCapabilityListeners.forEach { it(snapshot) }
2237
+ // Push the new capability into MediaSession.setAvailableCommands
2238
+ // for every connected controller so external surfaces (lock-
2239
+ // screen, Bluetooth AVRCP, Auto, Wear) grey out skip buttons
2240
+ // that the queue boundary makes invalid, and so the PLAY-bit
2241
+ // strip from `playerCommandsFor(_, hasMediaItems=false)` reaches
2242
+ // gearhead the moment the queue empties (and re-appears the
2243
+ // moment a new queue is set).
2244
+ serviceBinder?.refreshAvailableCommands()
2245
+ }
2246
+
2247
+ /**
2248
+ * Pick the appropriate wake-mode for the queue's URI mix.
2249
+ * `WAKE_MODE_NETWORK` (CPU + WiFi) when any track is remote
2250
+ * (http/https); `WAKE_MODE_LOCAL` (CPU only) when every track is
2251
+ * local (file:// / content:// / asset:// / RTSP-or-other-non-http
2252
+ * which the cache also doesn't consume). Empty queues default to
2253
+ * the conservative `WAKE_MODE_NETWORK` since we don't know what
2254
+ * the next setQueue will land.
2255
+ *
2256
+ * Delegates the scheme check to [UrlSchemes.isRemote] so the
2257
+ * classification stays consistent with [LookaheadCacheWriter].
2258
+ */
2259
+ private fun pickWakeMode(queue: List<TrackItem>): Int {
2260
+ if (queue.isEmpty()) return C.WAKE_MODE_NETWORK
2261
+ val anyRemote = queue.any { UrlSchemes.isRemote(it.url) }
2262
+ return if (anyRemote) C.WAKE_MODE_NETWORK else C.WAKE_MODE_LOCAL
2263
+ }
2264
+
2265
+ // --- Playback configuration
2266
+ //
2267
+ // Lightweight property writers — each hops to main, applies the
2268
+ // change, resolves. Matches iOS `TrackPlayer.swift` "Playback
2269
+ // configuration" block.
2270
+
2271
+ override fun setPlaybackMode(mode: PlaybackMode): Promise<Unit> =
2272
+ Promise.async {
2273
+ onMain {
2274
+ val (newState, effect) = playbackModeStateMachine.setRequested(
2275
+ state = playbackModeState,
2276
+ requested = mode,
2277
+ )
2278
+ playbackModeState = newState
2279
+ when (effect) {
2280
+ is PlaybackModeStateMachine.TransitionEffect.NoOp -> Unit
2281
+ is PlaybackModeStateMachine.TransitionEffect.ImmediateSwap -> {
2282
+ applyEngineSwapForMode(newState.active)
2283
+ }
2284
+ }
2285
+ }
2286
+ }
2287
+
2288
+ override fun getPlaybackMode(): PlaybackMode = playbackModeState.active
2289
+
2290
+ /**
2291
+ * Build a fresh engine of the requested kind and route the swap
2292
+ * through [PlaybackService.swapEngine] so service-level wiring
2293
+ * (audio-focus listener, `MediaSession.setPlayer`, Equalizer
2294
+ * multi-session re-attach) happens atomically. Caller has
2295
+ * already updated [playbackModeState] before invoking.
2296
+ *
2297
+ * Resume position is captured from the active engine BEFORE
2298
+ * the swap (the swap releases the old engine — reading
2299
+ * `newEngine.currentPositionMs` after would always return 0).
2300
+ *
2301
+ * Queue re-install goes through `MediaItemBuilder.buildMediaItems`
2302
+ * so HTTP headers, user-agent, MediaMetadata (title / artist /
2303
+ * album / artwork), and MIME hints survive the swap — the same
2304
+ * builder every other queue path uses. The new engine receives
2305
+ * a fresh `MediaSource.Factory` built via
2306
+ * `MediaItemBuilder.buildMediaSourceFactory` so per-config
2307
+ * HTTP headers + the lookahead cache wiring continue to apply
2308
+ * post-swap.
2309
+ */
2310
+ private fun applyEngineSwapForMode(active: PlaybackMode) {
2311
+ val service = serviceBinder?.service ?: return
2312
+ val activeEngine = serviceBinder?.engine
2313
+ // Capture pre-swap state — must read BEFORE constructing /
2314
+ // installing the new engine because `swapEngine` releases the
2315
+ // old one. The wasPlaying capture is what carries playback
2316
+ // continuity across the immediate engine swap.
2317
+ val resumeMs = (activeEngine?.currentPositionMs ?: 0L).coerceAtLeast(0L)
2318
+ val resumeIndex = currentTrackIndex
2319
+ val wasPlaying = activeEngine?.isPlaying ?: false
2320
+ // Per-leg fresh factory for CrossfadeEngine (sharing factories
2321
+ // across two ExoPlayer instances is a documented Media3
2322
+ // anti-pattern); GaplessEngine needs only one.
2323
+ val capturedConfig = config
2324
+ val capturedCache = lookaheadCache
2325
+ // When an AirPlay (push-PCM) session is active, stream through the
2326
+ // dedicated `AirPlayEngine` — a standalone engine bound directly to the
2327
+ // session's sink, with no forced float output and no audio processors, so
2328
+ // the receiver gets clean 16-bit PCM. It is completely separate from the
2329
+ // gapless / crossfade engines. The AirPlay branch wins regardless of the
2330
+ // user's requested mode (crossfade is unavailable while AirPlay is active —
2331
+ // the push wire protocol has no sender-side cross-stream mixing). On
2332
+ // disconnect `activeSession()` is null, so the swap falls through to the
2333
+ // user's requested mode (gapless or crossfade re-instates).
2334
+ val airplaySink =
2335
+ (com.margelo.nitro.queueplayer.cast.CastSinkRouter.activeSession()
2336
+ as? com.margelo.nitro.queueplayer.cast.CastSession.PcmStream)?.audioSink()
2337
+
2338
+ val newEngine: PlaybackEngine = when {
2339
+ airplaySink != null -> AirPlayEngine(
2340
+ context = service,
2341
+ airplaySink = airplaySink,
2342
+ mediaSourceFactory = MediaItemBuilder.buildMediaSourceFactory(
2343
+ context = service,
2344
+ config = capturedConfig,
2345
+ lookaheadCache = capturedCache,
2346
+ ),
2347
+ audioContentType = config.audioContentType,
2348
+ )
2349
+ active.kind == PlaybackModeKind.CROSSFADE -> {
2350
+ // PlaybackModeStateMachine.requireValid guarantees this is
2351
+ // non-null + within [1000, 12000] when kind == CROSSFADE.
2352
+ // Fail loud rather than silently degrading to 0 (which
2353
+ // would route through CrossfadeEngine's hard-cut path +
2354
+ // skip onCrossfadeBegin entirely).
2355
+ val crossfadeMs = active.crossfadeDurationMs
2356
+ ?: throw IllegalStateException(
2357
+ "PlaybackMode(crossfade) missing crossfadeDurationMs at engine construction"
2358
+ )
2359
+ CrossfadeEngine(
2360
+ context = service,
2361
+ mediaSourceFactoryProvider = {
2362
+ MediaItemBuilder.buildMediaSourceFactory(
2363
+ context = service,
2364
+ config = capturedConfig,
2365
+ lookaheadCache = capturedCache,
2366
+ )
2367
+ },
2368
+ audioContentType = config.audioContentType,
2369
+ crossfadeDurationMs = crossfadeMs.toLong(),
2370
+ )
2371
+ }
2372
+ else -> GaplessEngine(
2373
+ context = service,
2374
+ mediaSourceFactory = MediaItemBuilder.buildMediaSourceFactory(
2375
+ context = service,
2376
+ config = capturedConfig,
2377
+ lookaheadCache = capturedCache,
2378
+ ),
2379
+ audioContentType = config.audioContentType,
2380
+ )
2381
+ }
2382
+ service.swapEngine(newEngine)
2383
+ // No explicit `player` refresh needed — `player` is a computed
2384
+ // accessor that reads through `serviceBinder.engine.mediaSessionPlayer`
2385
+ // every call, so the next transport call resolves against the
2386
+ // new engine. This also covers CrossfadeEngine internal
2387
+ // role-swaps where leadingPlayer flips between playerA + playerB
2388
+ // at every fade boundary.
2389
+ // Re-install the canonical queue model on the new engine so
2390
+ // playback continues from the current track / position. The
2391
+ // post-swap engine starts empty by construction.
2392
+ if (resumeIndex >= 0 && resumeIndex < tracks.count()) {
2393
+ val media = MediaItemBuilder.buildMediaItems(service, tracks, lookaheadCache)
2394
+ newEngine.setMediaItems(
2395
+ items = media,
2396
+ startIndex = resumeIndex,
2397
+ startPositionMs = resumeMs,
2398
+ )
2399
+ // Honour pre-swap playing state so a mid-play setPlaybackMode
2400
+ // keeps playing on the new engine. Without this, every
2401
+ // immediate swap silently pauses the user.
2402
+ if (wasPlaying) {
2403
+ newEngine.play()
2404
+ }
2405
+ }
2406
+
2407
+ // Re-apply canonical config — a freshly-built engine starts at
2408
+ // defaults (repeat off, volume 1, speed 1), so without this a
2409
+ // gapless↔crossfade swap silently drops the user's settings.
2410
+ newEngine.setRepeatMode(repeatModeState)
2411
+ newEngine.setVolume(volumeState)
2412
+ newEngine.setPlaybackSpeed(playbackSpeedState)
2413
+ newEngine.setPitchCorrectionMode(pitchCorrectionModeState)
2414
+ // Drive the AirPlay receiver-display metadata sync. When the new engine is
2415
+ // the dedicated AirPlay engine, hand the now-active session this engine's
2416
+ // ExoPlayer — registered in AirPlayEngine.init, so CastSinkRouter.player()
2417
+ // returns it at this point — plus the main-dispatched scope. Activated
2418
+ // after the queue install so the first metadata push reads a populated
2419
+ // player, not a blank one. Deactivation is driven by AirPlayEngine.release()
2420
+ // when the engine is swapped out / released.
2421
+ if (newEngine is AirPlayEngine) {
2422
+ val session = com.margelo.nitro.queueplayer.cast.CastSinkRouter.activeSession()
2423
+ val player = com.margelo.nitro.queueplayer.cast.CastSinkRouter.player()
2424
+ if (session is com.margelo.nitro.queueplayer.cast.MetadataAwareSession && player != null) {
2425
+ session.onActivated(player, metadataSyncScope)
2426
+ }
2427
+ }
2428
+ }
2429
+
2430
+ override fun setPlaybackSpeed(rate: Double): Promise<Unit> =
2431
+ Promise.async {
2432
+ onMain {
2433
+ // Mirror `seekToInternal:890` non-finite guard. NaN →
2434
+ // undefined ExoPlayer audio behaviour at the AudioSink;
2435
+ // ±Infinity → IllegalArgumentException from
2436
+ // `PlaybackParameters` constructor.
2437
+ if (!rate.isFinite()) return@onMain
2438
+ setPlaybackSpeedInternal(rate)
2439
+ }
2440
+ }
2441
+
2442
+ /**
2443
+ * Main-thread body for [setPlaybackSpeed]. The engine applies the
2444
+ * active pitch-correction mode's pitch alongside the speed (voice/music
2445
+ * preserve pitch; none lets it follow rate). Exposed `@VisibleForTesting`
2446
+ * so Robolectric exercises the cast + speed-application logic rather than
2447
+ * just bypassing through `ExoPlayer.playbackParameters`.
2448
+ *
2449
+ * Diverges from iOS: on iOS `player.rate = Float(rate)` implicitly
2450
+ * starts playback when the player was paused. Android's
2451
+ * `PlaybackParameters` changes playback speed without touching
2452
+ * `playWhenReady`, matching Media3 consumer expectations. Callers
2453
+ * that want to resume playback must call play() separately.
2454
+ */
2455
+ @VisibleForTesting
2456
+ internal fun setPlaybackSpeedInternal(rate: Double) {
2457
+ // Clamp to a 0.25 floor: rate=0 produces undefined audio behaviour
2458
+ // at the AudioSink (PlaybackParameters(0f, pitch) is unspecified).
2459
+ // Cross-platform contract documented in TrackPlayer.nitro.ts.
2460
+ val safeRate = maxOf(0.25f, rate.toFloat())
2461
+ playbackSpeedState = safeRate
2462
+ if (player == null) return
2463
+ val engine = serviceBinder?.engine ?: return
2464
+ // The engine applies the active pitch-correction mode's pitch
2465
+ // alongside the speed (voice/music preserve pitch; none lets it
2466
+ // follow rate). At unity rate the resulting params are (1,1).
2467
+ engine.setPlaybackSpeed(safeRate)
2468
+ }
2469
+
2470
+ override fun setPitchCorrectionMode(mode: PitchCorrectionMode): Promise<Unit> =
2471
+ Promise.async {
2472
+ onMain {
2473
+ pitchCorrectionModeState = mode
2474
+ serviceBinder?.engine?.setPitchCorrectionMode(mode)
2475
+ }
2476
+ }
2477
+
2478
+ override fun setVolume(volume: Double): Promise<Unit> =
2479
+ Promise.async {
2480
+ onMain {
2481
+ // Mirror `seekToInternal:890` non-finite guard. NaN →
2482
+ // undefined audio output; ±Infinity → ditto.
2483
+ if (!volume.isFinite()) return@onMain
2484
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSetVolume(volume.toFloat())) return@onMain
2485
+ setVolumeInternal(volume)
2486
+ }
2487
+ }
2488
+
2489
+ /**
2490
+ * Main-thread body for [setVolume]. ExoPlayer accepts 0.0f..1.0f;
2491
+ * values outside the range are silently ignored at the audio sink.
2492
+ * JS callers are expected to clamp — matches iOS which passes
2493
+ * `Float(volume)` straight through.
2494
+ */
2495
+ @VisibleForTesting
2496
+ internal fun setVolumeInternal(volume: Double) {
2497
+ volumeState = volume.toFloat()
2498
+ if (player == null) return
2499
+ serviceBinder?.engine?.setVolume(volume.toFloat())
2500
+ }
2501
+
2502
+ // --- Sleep timer ---
2503
+
2504
+ override fun setSleepTimer(seconds: Double): Promise<Unit> =
2505
+ Promise.async { onMain { setSleepTimerInternal(seconds) } }
2506
+
2507
+ override fun setSleepTimerToTrackEnd(): Promise<Unit> =
2508
+ Promise.async { onMain { armSleepTimerToTrackEndInternal() } }
2509
+
2510
+ override fun clearSleepTimer(): Promise<Unit> =
2511
+ Promise.async { onMain { clearSleepTimerInternal() } }
2512
+
2513
+ override fun getSleepTimer(): SleepTimerState = syncMain { sleepTimerSnapshot() }
2514
+
2515
+ override fun onSleepTimerChange(callback: (state: SleepTimerState) -> Unit): () -> Unit {
2516
+ val id = sleepTimerListeners.add(callback)
2517
+ mainHandler.post {
2518
+ if (sleepTimerListeners.contains(id)) callback(sleepTimerSnapshot())
2519
+ }
2520
+ return { sleepTimerListeners.remove(id) }
2521
+ }
2522
+
2523
+ override fun onBufferStateChange(callback: (state: BufferState) -> Unit): () -> Unit {
2524
+ val id = bufferStateListeners.add(callback)
2525
+ // Buffer state is delivered by the engine state-change delegate, not the
2526
+ // progress tick — just fire once on subscribe with the current state so a
2527
+ // UI renders immediately.
2528
+ mainHandler.post {
2529
+ if (bufferStateListeners.contains(id)) callback(currentBufferState)
2530
+ }
2531
+ return { bufferStateListeners.remove(id) }
2532
+ }
2533
+
2534
+ override fun onFullyBufferedChange(callback: (fullyBuffered: Boolean) -> Unit): () -> Unit {
2535
+ val id = fullyBufferedListeners.add(callback)
2536
+ // Keep the progress tick alive (bufferedPosition grows continuously toward
2537
+ // duration) + fire once on subscribe with the current value.
2538
+ onMainSync { startProgressTickIfNeeded() }
2539
+ mainHandler.post {
2540
+ if (fullyBufferedListeners.contains(id)) callback(currentFullyBuffered)
2541
+ }
2542
+ return {
2543
+ fullyBufferedListeners.remove(id)
2544
+ onMainSync { maybeStopProgressTick() }
2545
+ }
2546
+ }
2547
+
2548
+ /** Arm a wall-clock duration timer; a non-positive value clears any timer. */
2549
+ @VisibleForTesting
2550
+ internal fun setSleepTimerInternal(seconds: Double) {
2551
+ if (!seconds.isFinite() || seconds <= 0.0) {
2552
+ clearSleepTimerInternal()
2553
+ return
2554
+ }
2555
+ restoreSleepTimerVolume()
2556
+ sleepTimerCore.armDuration(seconds, System.currentTimeMillis())
2557
+ startSleepTimerTick()
2558
+ emitSleepTimerChanged()
2559
+ }
2560
+
2561
+ @VisibleForTesting
2562
+ internal fun armSleepTimerToTrackEndInternal() {
2563
+ restoreSleepTimerVolume()
2564
+ sleepTimerCore.armEndOfTrack()
2565
+ startSleepTimerTick()
2566
+ emitSleepTimerChanged()
2567
+ }
2568
+
2569
+ @VisibleForTesting
2570
+ internal fun clearSleepTimerInternal() {
2571
+ val wasActive = sleepTimerCore.isActive
2572
+ restoreSleepTimerVolume()
2573
+ sleepTimerCore.clear()
2574
+ stopSleepTimerTick()
2575
+ if (wasActive) emitSleepTimerChanged()
2576
+ }
2577
+
2578
+ private fun sleepTimerSnapshot(): SleepTimerState {
2579
+ val now = System.currentTimeMillis()
2580
+ return SleepTimerState(
2581
+ active = sleepTimerCore.isActive,
2582
+ endsAtEpochMs = sleepTimerCore.deadlineEpochMs?.toDouble(),
2583
+ remainingSeconds = sleepTimerCore.remainingSeconds(now)?.toDouble(),
2584
+ endOfTrack = sleepTimerCore.isEndOfTrack,
2585
+ )
2586
+ }
2587
+
2588
+ private fun emitSleepTimerChanged() {
2589
+ val snapshot = sleepTimerSnapshot()
2590
+ sleepTimerListeners.forEach { it(snapshot) }
2591
+ }
2592
+
2593
+ private fun startSleepTimerTick() {
2594
+ if (sleepTimerRunnable != null) return
2595
+ val runnable = object : Runnable {
2596
+ override fun run() {
2597
+ if (sleepTimerRunnable == null) return
2598
+ onSleepTimerTick()
2599
+ // onSleepTimerTick nulls the runnable when the timer fires/clears; only
2600
+ // reschedule if we're still the active tick.
2601
+ if (sleepTimerRunnable === this) {
2602
+ mainHandler.postDelayed(this, SLEEP_TIMER_TICK_INTERVAL_MS)
2603
+ }
2604
+ }
2605
+ }
2606
+ sleepTimerRunnable = runnable
2607
+ mainHandler.postDelayed(runnable, SLEEP_TIMER_TICK_INTERVAL_MS)
2608
+ }
2609
+
2610
+ private fun stopSleepTimerTick() {
2611
+ sleepTimerRunnable?.let { mainHandler.removeCallbacks(it) }
2612
+ sleepTimerRunnable = null
2613
+ }
2614
+
2615
+ private fun onSleepTimerTick() {
2616
+ // While casting, the local engine is intentionally frozen (its position
2617
+ // never advances), so feed the state machine the receiver's position /
2618
+ // duration instead — otherwise the end-of-track tail rule never trips.
2619
+ val remote = com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote()
2620
+ val durationSec: Double
2621
+ val positionSec: Double
2622
+ if (remote != null) {
2623
+ val durMs = remote.durationMs.value
2624
+ durationSec = if (durMs <= 0L) 0.0 else durMs / 1000.0
2625
+ positionSec = maxOf(0L, remote.positionMs.value) / 1000.0
2626
+ } else {
2627
+ val durationMs = serviceBinder?.engine?.currentDurationMs ?: C.TIME_UNSET
2628
+ durationSec = if (durationMs == C.TIME_UNSET || durationMs <= 0L) 0.0 else durationMs / 1000.0
2629
+ positionSec = maxOf(0L, serviceBinder?.engine?.currentPositionMs ?: 0L) / 1000.0
2630
+ }
2631
+ val result = sleepTimerCore.tick(System.currentTimeMillis(), durationSec, positionSec)
2632
+ applySleepTimerFade(result.fadeFraction)
2633
+ if (result.stateChanged) emitSleepTimerChanged()
2634
+ if (result.pauseNow) {
2635
+ // Route the boundary pause to the receiver when casting (it emits its own
2636
+ // paused state); only the local-engine pause needs the sleep-timer flag —
2637
+ // `onStateMaybeChanged` would otherwise label the paused emit 'user'.
2638
+ if (!com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePause()) {
2639
+ pendingSleepTimerPause = true
2640
+ serviceBinder?.engine?.pause()
2641
+ }
2642
+ }
2643
+ if (!sleepTimerCore.isActive) stopSleepTimerTick()
2644
+ }
2645
+
2646
+ private fun applySleepTimerFade(fraction: Double) {
2647
+ // Fade is local-only: the receiver's volume is user/system-owned and
2648
+ // `volumeState` tracks the local level, so ramping it on the receiver would
2649
+ // clobber the cast volume. When casting the boundary pause still routes to
2650
+ // the receiver; the pre-pause fade is skipped. Undo any in-progress local
2651
+ // fade first so a cast session appearing mid-fade can't strand the local
2652
+ // engine at a ramped-down volume for a later cast→local return.
2653
+ if (com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote() != null) {
2654
+ restoreSleepTimerVolume()
2655
+ return
2656
+ }
2657
+ if (fraction < 1.0) {
2658
+ serviceBinder?.engine?.setVolume((volumeState * fraction).toFloat())
2659
+ sleepTimerFading = true
2660
+ } else if (sleepTimerFading) {
2661
+ serviceBinder?.engine?.setVolume(volumeState)
2662
+ sleepTimerFading = false
2663
+ }
2664
+ }
2665
+
2666
+ private fun restoreSleepTimerVolume() {
2667
+ if (sleepTimerFading) {
2668
+ serviceBinder?.engine?.setVolume(volumeState)
2669
+ sleepTimerFading = false
2670
+ }
2671
+ }
2672
+
2673
+ override fun setRepeatMode(mode: RepeatMode): Promise<Unit> =
2674
+ Promise.async { onMain { setRepeatModeInternal(mode) } }
2675
+
2676
+ override fun setRemoteControls(options: RemoteControlOptions): Promise<Unit> =
2677
+ Promise.async { onMain { setRemoteControlsInternal(options) } }
2678
+
2679
+ /**
2680
+ * Store the remote-control config and push it to the playback service, which
2681
+ * swaps the notification button layout (track vs interval) live and points
2682
+ * the forwarding player at the new jump intervals. No-op on the service side
2683
+ * until a session exists; the config is re-applied on session build.
2684
+ */
2685
+ @VisibleForTesting
2686
+ internal fun setRemoteControlsInternal(options: RemoteControlOptions) {
2687
+ // Guard nonsensical negatives (a negative forward interval would seek
2688
+ // backward); non-positive is clamped to 0.
2689
+ remoteSkipMode = options.skipMode
2690
+ remoteForwardSeconds = options.forwardJumpInterval.coerceAtLeast(0.0)
2691
+ remoteBackwardSeconds = options.backwardJumpInterval.coerceAtLeast(0.0)
2692
+ serviceBinder?.setRemoteControls(
2693
+ options.skipMode,
2694
+ (remoteForwardSeconds * 1000).toLong(),
2695
+ (remoteBackwardSeconds * 1000).toLong(),
2696
+ )
2697
+ }
2698
+
2699
+ override fun getRemoteControls(): RemoteControlOptions = syncMain {
2700
+ RemoteControlOptions(remoteSkipMode, remoteForwardSeconds, remoteBackwardSeconds)
2701
+ }
2702
+
2703
+ override fun getBufferState(): BufferState = currentBufferState
2704
+
2705
+ override fun getIsSeekable(): Boolean = syncMain { player?.isCurrentMediaItemSeekable ?: true }
2706
+
2707
+ override fun isFullyBuffered(): Boolean = currentFullyBuffered
2708
+
2709
+ /**
2710
+ * Store the repeat mode on [repeatModeState] (consumed by
2711
+ * [QueueSkipArithmetic] when the user presses Next/Previous)
2712
+ * AND push it onto ExoPlayer's native repeat mode so that
2713
+ * natural end-of-queue advance honours the same contract.
2714
+ *
2715
+ * Without the native mirror, `RepeatMode.QUEUE` at the last
2716
+ * track's natural end would transition ExoPlayer into
2717
+ * `STATE_ENDED` instead of wrapping to track 0 — skipToNext
2718
+ * would still wrap correctly via the arithmetic, but the
2719
+ * auto-advance path (gapless) would break at the boundary.
2720
+ */
2721
+ @VisibleForTesting
2722
+ internal fun setRepeatModeInternal(mode: RepeatMode) {
2723
+ repeatModeState = mode
2724
+ if (player == null) return
2725
+ serviceBinder?.engine?.setRepeatMode(mode)
2726
+ // Both canSkipNext and canSkipPrevious depend on repeat mode
2727
+ // (e.g. at idx 0 / last index, OFF↔QUEUE flips the boolean
2728
+ // because QUEUE wraps). Recompute is cheap; dedup keeps the
2729
+ // listener silent when the (prev, next) tuple is unchanged.
2730
+ recomputeCapabilities()
2731
+ }
2732
+
2733
+ /// Destructive shuffle. Mutates `tracks` in-place via
2734
+ /// Fisher-Yates (Kotlin stdlib `shuffled()`), resets
2735
+ /// `currentTrackIndex` to 0, and resumes playback on the new
2736
+ /// tracks[0] if the player was playing pre-shuffle. Returns the
2737
+ /// post-shuffle snapshot for the consumer app to immediately
2738
+ /// reflect the new ordering in its UI.
2739
+ ///
2740
+ /// Empty queue → returns `ShuffleResult(tracks=[], currentIndex=-1, currentTrack=null)`
2741
+ /// without mutating state.
2742
+ override fun shuffleQueue(): Promise<ShuffleResult> =
2743
+ Promise.async { onMain { shuffleQueueInternal() } }
2744
+
2745
+ @VisibleForTesting
2746
+ internal fun shuffleQueueInternal(): ShuffleResult {
2747
+ val p = player
2748
+ val ctx = boundContext
2749
+ if (tracks.isEmpty() || p == null || ctx == null) {
2750
+ // Empty queue OR no player attached (configure() not yet called).
2751
+ // Return empty snapshot WITHOUT mutating tracks — silently
2752
+ // shuffling an in-memory queue while the player can't actually
2753
+ // start would lie to the consumer.
2754
+ return ShuffleResult(
2755
+ tracks = emptyArray(),
2756
+ currentIndex = -1.0,
2757
+ currentTrack = null,
2758
+ )
2759
+ }
2760
+
2761
+ // Capture playing state BEFORE the mutation so we know whether to
2762
+ // resume after the rebuild.
2763
+ val wasPlaying = p.isPlaying
2764
+
2765
+ // Kotlin stdlib `shuffled()` is Fisher-Yates (documented).
2766
+ val newTracks = tracks.shuffled()
2767
+
2768
+ this.tracks = newTracks
2769
+ this.currentTrackIndex = 0
2770
+ pendingTrackChangeReason = TrackChangeReason.QUEUE_REPLACED
2771
+ // Destructive shuffle replaces every position; reset retry budget.
2772
+ val attempts = effectiveAutoRetries()
2773
+ retryAttemptsRemaining.clear()
2774
+ for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
2775
+
2776
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
2777
+ serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
2778
+ p.prepare()
2779
+ serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
2780
+
2781
+ // Resume playback if the user was playing before the shuffle.
2782
+ // Stays paused otherwise — consumer can call play() if they
2783
+ // want to start from the new track 0.
2784
+ if (wasPlaying) serviceBinder?.engine?.play()
2785
+
2786
+ rescheduleLookahead()
2787
+ recomputeCapabilities()
2788
+
2789
+ return ShuffleResult(
2790
+ tracks = newTracks.toTypedArray(),
2791
+ currentIndex = 0.0,
2792
+ currentTrack = newTracks[0],
2793
+ )
2794
+ }
2795
+
2796
+ // --- State snapshots: player-local readers
2797
+ //
2798
+ // Media3 1.9.3 requires ExoPlayer reads on the application
2799
+ // thread: `ExoPlayerImpl.verifyApplicationThread()` asserts on
2800
+ // `playbackState`, `isPlaying`, `volume`, `playbackParameters`,
2801
+ // etc. Nitro dispatches synchronous methods on the JS thread,
2802
+ // so the non-Promise readers below funnel through `syncMain`
2803
+ // to hop to main and block until the snapshot completes. The
2804
+ // blocking wait is acceptable because: (a) each read is a single
2805
+ // property access, (b) once a future listener wires a listener-backed state
2806
+ // cache, these readers switch to the cached value and this hop
2807
+ // goes away.
2808
+
2809
+ /**
2810
+ * Snapshot of the ExoPlayer's current state mapped onto the
2811
+ * public [PlayerState] enum. Safe to call from any thread —
2812
+ * hops to main via [syncMain] so Media3's thread-assertion does
2813
+ * not fire.
2814
+ */
2815
+ override fun getState(): PlayerState =
2816
+ syncMain { getStateInternal() }
2817
+
2818
+ @VisibleForTesting
2819
+ internal fun getStateInternal(): PlayerState {
2820
+ val p = player ?: return PlayerState.NONE
2821
+ return TransportStateTranslator.translate(
2822
+ playbackState = p.playbackState,
2823
+ isPlaying = p.isPlaying,
2824
+ playWhenReady = p.playWhenReady,
2825
+ playbackSuppressionReason = p.playbackSuppressionReason,
2826
+ hasTracks = tracks.isNotEmpty(),
2827
+ currentTrackIndex = currentTrackIndex
2828
+ )
2829
+ }
2830
+
2831
+ // No `syncMain` hop on the next 3 getters: each reads a single
2832
+ // `@Volatile` field that is only written on main, so the JVM
2833
+ // memory model gives the cross-thread happens-before edge for
2834
+ // free. iOS uses `onMainSync` because Swift has no `@Volatile`-
2835
+ // equivalent storage class.
2836
+ override fun getCurrentTrackIndex(): Double = currentTrackIndex.toDouble()
2837
+
2838
+ override fun getCurrentTrackSource(): TrackSource? = currentTrackSource
2839
+
2840
+ override fun getCanSkipNext(): Boolean = cachedSkipCapability.canSkipNext
2841
+ override fun getCanSkipPrevious(): Boolean = cachedSkipCapability.canSkipPrevious
2842
+ override fun getSkipCapability(): SkipCapability = cachedSkipCapability
2843
+
2844
+ override fun getPlaybackSpeed(): Double =
2845
+ syncMain { playbackSpeedState.toDouble() }
2846
+
2847
+ override fun getPitchCorrectionMode(): PitchCorrectionMode =
2848
+ syncMain { pitchCorrectionModeState }
2849
+
2850
+ override fun getVolume(): Double =
2851
+ syncMain { volumeState.toDouble() }
2852
+
2853
+ // --- Events — public registration surface
2854
+ //
2855
+ // Each event stores a single callback and returns an unsubscribe
2856
+ // closure that clears it. Registration + clear funnel through
2857
+ // `onMainSync` so a JS-side register/clear race against a listener
2858
+ // fire (also on main) cannot corrupt the optional slot.
2859
+ //
2860
+ // onProgress additionally (re)starts the periodic tick Runnable on
2861
+ // register + stops it on the final unsubscribe, so the timer only
2862
+ // runs when a subscriber exists.
2863
+
2864
+ override fun onStateChange(
2865
+ callback: (state: PlayerState, reason: StateChangeReason) -> Unit
2866
+ ): () -> Unit {
2867
+ val id = stateChangeListeners.add(callback)
2868
+ return { stateChangeListeners.remove(id) }
2869
+ }
2870
+
2871
+ override fun onTrackChange(
2872
+ callback: (track: TrackItem?, index: Double, reason: TrackChangeReason, transitionGapMs: Double?) -> Unit
2873
+ ): () -> Unit {
2874
+ val id = trackChangeListeners.add(callback)
2875
+ return { trackChangeListeners.remove(id) }
2876
+ }
2877
+
2878
+ override fun onProgress(
2879
+ callback: (progress: PlayerProgress) -> Unit
2880
+ ): () -> Unit {
2881
+ val id = progressListeners.add(callback)
2882
+ onMainSync { startProgressTickIfNeeded() }
2883
+ return {
2884
+ progressListeners.remove(id)
2885
+ // The tick also serves milestone + fully-buffered subscribers — stop only
2886
+ // when nothing else needs it.
2887
+ onMainSync { maybeStopProgressTick() }
2888
+ }
2889
+ }
2890
+
2891
+ override fun onPlaybackMilestone(
2892
+ callback: (milestone: Double, trackIndex: Double) -> Unit
2893
+ ): () -> Unit {
2894
+ val id = milestoneListeners.add(callback)
2895
+ onMainSync { startProgressTickIfNeeded() }
2896
+ return {
2897
+ milestoneListeners.remove(id)
2898
+ onMainSync { maybeStopProgressTick() }
2899
+ }
2900
+ }
2901
+
2902
+ override fun onError(callback: (error: PlaybackError) -> Unit): () -> Unit {
2903
+ val id = errorListeners.add(callback)
2904
+ return { errorListeners.remove(id) }
2905
+ }
2906
+
2907
+ override fun onQueueEnd(callback: () -> Unit): () -> Unit {
2908
+ val id = queueEndListeners.add(callback)
2909
+ return { queueEndListeners.remove(id) }
2910
+ }
2911
+
2912
+ /**
2913
+ * Subscribe to skip-capability changes. Auto-fires the callback
2914
+ * once on registration with the current cached values (mirrors
2915
+ * Media3 `Player.Listener.onAvailableCommandsChanged`) so new
2916
+ * subscribers don't have to seed via the getters first. The
2917
+ * auto-fire hops to main so the cache read happens on the same
2918
+ * thread that mutates it; subsequent fires happen synchronously
2919
+ * from [recomputeCapabilities] (which is itself main-bound).
2920
+ *
2921
+ * **Dispose race guard:** the deferred main fire checks the
2922
+ * subscriber is still registered before invoking the callback.
2923
+ * Strict-mode subscribe → dispose → subscribe sequences fire
2924
+ * the cleanup before the deferred fire runs; without the guard,
2925
+ * the auto-fire would call into an already-unmounted React
2926
+ * component's `setSnapshot`.
2927
+ */
2928
+ override fun onSkipCapabilityChange(
2929
+ callback: (capability: SkipCapability) -> Unit
2930
+ ): () -> Unit {
2931
+ val id = skipCapabilityListeners.add(callback)
2932
+ mainHandler.post {
2933
+ if (skipCapabilityListeners.contains(id)) {
2934
+ callback(cachedSkipCapability)
2935
+ }
2936
+ }
2937
+ return { skipCapabilityListeners.remove(id) }
2938
+ }
2939
+
2940
+ // --- Automotive / voice handler registration ---
2941
+
2942
+ /**
2943
+ * Atomically replace the cached `BrowseSnapshot` on the bound
2944
+ * [PlaybackService] and notify every connected `MediaController`
2945
+ * that the affected parent IDs changed so they re-pull. Push-
2946
+ * semantics — most-recent call wins. No-op when the service
2947
+ * isn't bound (the snapshot will land on the next consumer call
2948
+ * once binding completes).
2949
+ */
2950
+ override fun setBrowseSnapshot(snapshot: BrowseSnapshot) {
2951
+ val binder = serviceBinder
2952
+ if (binder == null) {
2953
+ // Service not bound yet (cold-start before configure binds) or
2954
+ // mid-rebirth (disconnect window). Stash the latest snapshot;
2955
+ // [drainPendingSnapshot] applies it once the binder is live.
2956
+ // Most-recent-wins — the consumer's latest push is what AA
2957
+ // sees, even if a stale one was queued.
2958
+ pendingSnapshot = snapshot
2959
+ return
2960
+ }
2961
+ val service = binder.service
2962
+ val changedParents = service.browseDataProvider.setSnapshot(snapshot)
2963
+ // Always include the lib-owned stable root id. Cross-process
2964
+ // controllers (Android Auto's gearhead) subscribed under
2965
+ // `LIB_ROOT_ID` from the initial `onGetLibraryRoot` call;
2966
+ // `notifyChildrenChanged` must fire on THAT id (not the
2967
+ // consumer-defined `snapshot.rootId`) for AA to refresh its
2968
+ // browse drawer. The consumer's rootId is still honoured by the
2969
+ // BrowseDataProvider's section indexing — this fan-out just adds
2970
+ // the lib's external-facing root.
2971
+ val withLibRoot = changedParents + PlaybackServiceCallback.LIB_ROOT_ID
2972
+ binder.handler.post { service.notifyChildrenChangedForParents(withLibRoot) }
2973
+ }
2974
+
2975
+ /** Latest snapshot the consumer pushed while the service binder
2976
+ * was null. Drained on the next [onServiceConnected]. Single-slot
2977
+ * (most-recent-wins) — the consumer's latest push is what AA sees
2978
+ * after the rebind, even if a stale one was queued earlier. */
2979
+ @Volatile
2980
+ private var pendingSnapshot: BrowseSnapshot? = null
2981
+
2982
+ @MainThread
2983
+ internal fun drainPendingSnapshot() {
2984
+ val snapshot = pendingSnapshot ?: return
2985
+ pendingSnapshot = null
2986
+ setBrowseSnapshot(snapshot)
2987
+ }
2988
+
2989
+ override fun onBrowseRequest(
2990
+ callback: (parentId: String) -> Promise<Promise<Array<BrowseItem>>>
2991
+ ): () -> Unit = deferredRegister { it.registerOnBrowseRequest(callback) }
2992
+
2993
+ override fun onSearchRequest(
2994
+ callback: (query: String) -> Promise<Promise<Array<BrowseItem>>>
2995
+ ): () -> Unit = deferredRegister { it.registerOnSearchRequest(callback) }
2996
+
2997
+ override fun onPlayFromSearchRequest(
2998
+ callback: (request: MediaSearchRequest) -> Promise<Promise<Array<TrackItem>>>
2999
+ ): () -> Unit = deferredRegister { it.registerOnPlayFromSearchRequest(callback) }
3000
+
3001
+ override fun onPlayFromIdRequest(
3002
+ callback: (mediaId: String) -> Promise<Promise<Array<TrackItem>>>
3003
+ ): () -> Unit = deferredRegister { it.registerOnPlayFromIdRequest(callback) }
3004
+
3005
+ // Donation is iOS-only (Siri INVocabulary). Accepted on Android to
3006
+ // keep the cross-platform spec shape consistent; discarded silently.
3007
+ // JSDoc in the .nitro.ts spec documents the asymmetry.
3008
+ override fun donateVoiceVocabulary(vocabulary: VoiceVocabulary) {}
3009
+
3010
+ override fun onCarConnect(callback: () -> Unit): () -> Unit =
3011
+ deferredRegister { it.registerOnCarConnect(callback) }
3012
+
3013
+ override fun onCarDisconnect(callback: () -> Unit): () -> Unit =
3014
+ deferredRegister { it.registerOnCarDisconnect(callback) }
3015
+
3016
+ override fun onServiceReady(
3017
+ callback: (reason: ServiceReadyReason) -> Unit
3018
+ ): () -> Unit = deferredRegister { it.registerOnServiceReady(callback) }
3019
+
3020
+ /** Registrations the consumer made BEFORE [serviceBinder] became
3021
+ * non-null. Drained in [drainPendingBrowseRegistrations] once the
3022
+ * service binds. Each entry installs itself against the live
3023
+ * registry + stores its disposer in the entry's own slot so
3024
+ * consumer dispose-calls fan out correctly to the active
3025
+ * registration. */
3026
+ private val pendingBrowseRegistrations =
3027
+ java.util.Collections.synchronizedList(mutableListOf<() -> Unit>())
3028
+
3029
+ /**
3030
+ * Wrap a `BrowseCallbackRegistry.registerOnXxx` call so it can be
3031
+ * invoked before the service is bound. Pre-bind: the registration
3032
+ * is queued; the returned disposer cancels it from the queue (and
3033
+ * disposes the live registration too, if the queue has since been
3034
+ * drained). Post-bind: registers immediately. Either way the
3035
+ * returned disposer matches the consumer's expectation that
3036
+ * calling it tears the registration down regardless of timing.
3037
+ *
3038
+ * Past failure: `bootstrap.ts` (demo) calls `registerPlaybackService`
3039
+ * at bundle-load time, which fires every `onCarConnect` /
3040
+ * `onPlayFromIdRequest` / etc. BEFORE `configure()` has bound the
3041
+ * service. The old "?: NO_OP_DISPOSER" fallback silently dropped
3042
+ * every registration; the carService thought it had wired up but
3043
+ * the lib had nothing recorded, so Android Auto's later bind hit
3044
+ * `dispatchCarConnect` with a null callback + timed out → AA
3045
+ * "No items".
3046
+ */
3047
+ private fun deferredRegister(
3048
+ register: (BrowseCallbackRegistry) -> () -> Unit
3049
+ ): () -> Unit {
3050
+ serviceBinder?.let {
3051
+ return register(it.service.browseCallbacks)
3052
+ }
3053
+ val liveDisposer = arrayOf<(() -> Unit)?>(null)
3054
+ var canceled = false
3055
+ val flush = {
3056
+ val binder = serviceBinder
3057
+ if (!canceled && binder != null) {
3058
+ liveDisposer[0] = register(binder.service.browseCallbacks)
3059
+ }
3060
+ }
3061
+ pendingBrowseRegistrations.add(flush)
3062
+ return {
3063
+ canceled = true
3064
+ pendingBrowseRegistrations.remove(flush)
3065
+ liveDisposer[0]?.invoke()
3066
+ liveDisposer[0] = null
3067
+ }
3068
+ }
3069
+
3070
+ /** Apply every pending browse registration against the now-live
3071
+ * service. Called from the service-connection callback. */
3072
+ @MainThread
3073
+ internal fun drainPendingBrowseRegistrations() {
3074
+ val drained: List<() -> Unit>
3075
+ synchronized(pendingBrowseRegistrations) {
3076
+ drained = pendingBrowseRegistrations.toList()
3077
+ pendingBrowseRegistrations.clear()
3078
+ }
3079
+ drained.forEach { it() }
3080
+ }
3081
+
3082
+ // --- Lookahead cache ---
3083
+
3084
+ override fun setLookaheadCache(config: LookaheadCacheConfig): Promise<Unit> =
3085
+ Promise.async {
3086
+ onMain {
3087
+ setLookaheadCacheInternal(config)
3088
+ }
3089
+ }
3090
+
3091
+ override fun getLookaheadCacheStatus(): CacheStatus =
3092
+ syncMain { buildCacheStatusSnapshot() }
3093
+
3094
+ override fun clearLookaheadCache(): Promise<Unit> =
3095
+ Promise.async { onMain { clearLookaheadCacheInternal() } }
3096
+
3097
+ override fun onCacheStatusChange(callback: (status: CacheStatus) -> Unit): () -> Unit {
3098
+ val id = cacheStatusListeners.add(callback)
3099
+ return { cacheStatusListeners.remove(id) }
3100
+ }
3101
+
3102
+ /**
3103
+ * Returns the active item's runtime audio format, or the null
3104
+ * variant when nothing is playable / format hasn't loaded yet.
3105
+ * Cached by [refreshNowPlayingFormatForActiveItem]; never blocks.
3106
+ *
3107
+ * An empty / out-of-range queue short-circuits to NULL regardless
3108
+ * of the cache — the canonical-state branch below.
3109
+ */
3110
+ override fun getNowPlayingFormat(): Variant_NullType_NowPlayingFormat {
3111
+ // Lib canonical state takes precedence over the cached format.
3112
+ // After `clearQueue` / queue-end the underlying ExoPlayer's
3113
+ // `currentMediaItem` can briefly retain the last item; without
3114
+ // this guard the read returns the prior track's stale codec /
3115
+ // bitrate / duration even though `currentTrackIndex == -1` and
3116
+ // the JS-facing state shows track == null.
3117
+ if (tracks.isEmpty() || currentTrackIndex < 0 || currentTrackIndex >= tracks.size) {
3118
+ return Variant_NullType_NowPlayingFormat.create(com.margelo.nitro.core.NullType.NULL)
3119
+ }
3120
+ val cached = lastEmittedFormat
3121
+ return if (cached == null) Variant_NullType_NowPlayingFormat.create(com.margelo.nitro.core.NullType.NULL)
3122
+ else Variant_NullType_NowPlayingFormat.create(cached)
3123
+ }
3124
+
3125
+ override fun onNowPlayingFormatChange(
3126
+ callback: (format: Variant_NullType_NowPlayingFormat?) -> Unit
3127
+ ): () -> Unit {
3128
+ val id = nowPlayingFormatListeners.add(callback)
3129
+ return { nowPlayingFormatListeners.remove(id) }
3130
+ }
3131
+
3132
+ // ReplayGain mode — JS-facing surface. Writes through the
3133
+ // process-wide [ReplayGainConfig] singleton; the active engine
3134
+ // (gapless or crossfade) reads the mode live from
3135
+ // `ReplayGainConfig.getMode()` on every metadata-driven recompute,
3136
+ // and the recompute call below handles the user-flips-mode-mid-track
3137
+ // case where no fresh metadata is going to fire. The cached
3138
+ // per-engine `ReplayGainData` re-selects under the new mode and the
3139
+ // processor sees the updated linear gain within ~1 audio buffer.
3140
+ override fun setReplayGainMode(mode: ReplayGainMode): Promise<Unit> =
3141
+ Promise.async {
3142
+ ReplayGainConfig.setMode(mode)
3143
+ onMain {
3144
+ when (val engine = serviceBinder?.engine) {
3145
+ is GaplessEngine -> engine.recomputeReplayGainFromConfig()
3146
+ is CrossfadeEngine -> engine.recomputeReplayGainFromConfig()
3147
+ else -> Unit
3148
+ }
3149
+ refreshNowPlayingFormatForActiveItem()
3150
+ }
3151
+ }
3152
+
3153
+ override fun getReplayGainMode(): ReplayGainMode = ReplayGainConfig.getMode()
3154
+
3155
+ /**
3156
+ * Re-resolve the active item's format and emit if it differs from
3157
+ * the last cached value. Call sites: [Player.Listener.onMediaItemTransition]
3158
+ * (item change → emit null until format loads) and
3159
+ * [Player.Listener.onTracksChanged] (format ready → emit real value).
3160
+ * Same-MediaItem identity check + value-equality dedup suppress
3161
+ * redundant fires from Media3's frequent track-set events.
3162
+ */
3163
+ @MainThread
3164
+ internal fun refreshNowPlayingFormatForActiveItem() {
3165
+ val p = player
3166
+ if (p == null) {
3167
+ if (lastEmittedFormat != null || lastEmittedFormatMediaItem != null) {
3168
+ lastEmittedFormat = null
3169
+ lastEmittedFormatMediaItem = null
3170
+ nowPlayingFormatListeners.forEach {
3171
+ it(Variant_NullType_NowPlayingFormat.create(com.margelo.nitro.core.NullType.NULL))
3172
+ }
3173
+ }
3174
+ return
3175
+ }
3176
+ val currentItem = p.currentMediaItem
3177
+ if (currentItem == null) {
3178
+ if (lastEmittedFormat != null || lastEmittedFormatMediaItem != null) {
3179
+ lastEmittedFormat = null
3180
+ lastEmittedFormatMediaItem = null
3181
+ nowPlayingFormatListeners.forEach {
3182
+ it(Variant_NullType_NowPlayingFormat.create(com.margelo.nitro.core.NullType.NULL))
3183
+ }
3184
+ }
3185
+ return
3186
+ }
3187
+ val resolved = NowPlayingFormatExtractor.extract(p, serviceBinder?.engine)
3188
+ val itemChanged = lastEmittedFormatMediaItem !== currentItem
3189
+ if (itemChanged && lastEmittedFormat != null) {
3190
+ lastEmittedFormat = null
3191
+ lastEmittedFormatMediaItem = null
3192
+ nowPlayingFormatListeners.forEach {
3193
+ it(Variant_NullType_NowPlayingFormat.create(com.margelo.nitro.core.NullType.NULL))
3194
+ }
3195
+ }
3196
+ if (resolved == null) return
3197
+ // Stamp the source identity at the same point we cache the format,
3198
+ // never before — otherwise an early `extract` returning null pins
3199
+ // the new MediaItem in cache without an emit, and the followup
3200
+ // onTracksChanged sees `itemChanged == false` and skips the resolve.
3201
+ if (resolved == lastEmittedFormat && lastEmittedFormatMediaItem === currentItem) return
3202
+ lastEmittedFormat = resolved
3203
+ lastEmittedFormatMediaItem = currentItem
3204
+ nowPlayingFormatListeners.forEach {
3205
+ it(Variant_NullType_NowPlayingFormat.create(resolved))
3206
+ }
3207
+ }
3208
+
3209
+ /**
3210
+ * Apply a new lookahead cache config at runtime. Both dimensions are
3211
+ * live — no configure() cycle needed:
3212
+ *
3213
+ * - `enabled` toggle:
3214
+ * - true → false: tears down the WRITER (no more proactive
3215
+ * prefetch). The CACHE stays alive — Media3's CacheDataSource
3216
+ * in the player's MediaSource.Factory still reads existing
3217
+ * entries, so disabling can't yank cache out from under
3218
+ * in-flight reads.
3219
+ * - false → true: rebuilds the writer against the existing cache
3220
+ * (the cache is built at configure() regardless of `enabled`),
3221
+ * so caching turns on without a configure() cycle.
3222
+ *
3223
+ * - `lookaheadCount` change: live update via
3224
+ * `LookaheadCacheWriter.defaultLookaheadCount`. Applies on the
3225
+ * next reschedule; in-flight downloads aren't interrupted.
3226
+ *
3227
+ * Cache SIZE + eviction policy are not part of this runtime config — both are
3228
+ * fixed at `configure()` from `PlayerConfig` (Media3's SimpleCache is immutable
3229
+ * after construction). Control actual usage at runtime via `enabled` +
3230
+ * `lookaheadCount`.
3231
+ *
3232
+ * After any change, emits a status snapshot so consumers see the new
3233
+ * config.
3234
+ */
3235
+ @MainThread
3236
+ @VisibleForTesting
3237
+ internal fun setLookaheadCacheInternal(newConfig: LookaheadCacheConfig) {
3238
+ val previous = this.lookaheadConfig
3239
+ this.lookaheadConfig = newConfig
3240
+
3241
+ val countChanged = newConfig.lookaheadCount != previous.lookaheadCount
3242
+ val enableChanged = newConfig.enabled != previous.enabled
3243
+
3244
+ // Disable → tear down WRITER only. Cache stays for player reads.
3245
+ if (enableChanged && !newConfig.enabled) {
3246
+ lookaheadCacheWriter?.release()
3247
+ lookaheadCacheWriter = null
3248
+ }
3249
+
3250
+ // Enable → (re)build the WRITER against the existing cache. The cache is
3251
+ // constructed at configure() regardless of `enabled`, so turning caching on
3252
+ // is a runtime switch with no configure() cycle. Uses the stored config's
3253
+ // http params (headers / userAgent / timeout) captured at configure time.
3254
+ if (enableChanged && newConfig.enabled && lookaheadCacheWriter == null) {
3255
+ val cache = lookaheadCache
3256
+ if (cache != null) {
3257
+ lookaheadCacheWriter = LookaheadCacheWriter.create(
3258
+ cache = cache,
3259
+ httpFactory = MediaItemBuilder.buildHttpFactory(
3260
+ headers = config.httpHeaders,
3261
+ userAgent = config.userAgent,
3262
+ networkTimeoutMs = MediaItemBuilder.effectiveNetworkTimeoutMs(
3263
+ config.networkTimeoutMs
3264
+ )
3265
+ )
3266
+ ).apply {
3267
+ defaultLookaheadCount =
3268
+ newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3269
+ onTrackProcessed = { onMainPostStatusEmit() }
3270
+ }
3271
+ rescheduleLookahead()
3272
+ }
3273
+ }
3274
+
3275
+ // Live count update — safe regardless of player lifecycle. Skipped when
3276
+ // the enable branch above just rebuilt the writer (it already applied the
3277
+ // new count + rescheduled), so a single enable+count change reschedules once.
3278
+ val writer = lookaheadCacheWriter
3279
+ if (countChanged && !enableChanged && writer != null) {
3280
+ writer.defaultLookaheadCount =
3281
+ newConfig.lookaheadCount.toInt().coerceAtLeast(0)
3282
+ rescheduleLookahead()
3283
+ }
3284
+
3285
+ emitCacheStatus()
3286
+ }
3287
+
3288
+ @VisibleForTesting
3289
+ internal fun clearLookaheadCacheInternal() {
3290
+ val cache = lookaheadCache ?: return
3291
+ // Cancel any in-flight prefetch first so we don't race with the
3292
+ // wipe (CacheWriter mid-write into a key we just removed would
3293
+ // re-create the entry seconds later). We deliberately do NOT
3294
+ // call rescheduleLookahead() here — an immediate refill makes
3295
+ // `clearLookaheadCache` impossible to observe at zero from JS,
3296
+ // and "clear" semantically means "wipe and stop" (consumer can
3297
+ // re-trigger prefetch via setLookaheadCache, skipToIndex, or
3298
+ // setQueue). Matches iOS.
3299
+ lookaheadCacheWriter?.cancel()
3300
+ cache.clear()
3301
+ emitCacheStatus()
3302
+ }
3303
+
3304
+ /**
3305
+ * Build a [CacheStatus] snapshot from the current cache + writer
3306
+ * state. Synchronous — called inside `syncMain` from
3307
+ * [getLookaheadCacheStatus].
3308
+ */
3309
+ private fun buildCacheStatusSnapshot(): CacheStatus {
3310
+ val cache = lookaheadCache
3311
+ val writer = lookaheadCacheWriter
3312
+ val currentSizeBytes = cache?.currentSizeBytes() ?: 0L
3313
+ // Queue-agnostic: count fully-cached entries in the cache itself,
3314
+ // not the intersection with `self.tracks`. Mirrors the semantics
3315
+ // of `currentSizeMb` (total cache disk usage) and `currentlyCaching`
3316
+ // (active downloads) — all three reflect cache state independent
3317
+ // of any current queue. Per-track local/stream/cache attribution
3318
+ // for the active queue is exposed via `getCurrentTrackSource()`.
3319
+ val tracksFullyCached = cache?.fullyCachedCount() ?: 0
3320
+ val currentlyCaching = writer?.currentlyDownloadingUrl
3321
+ ?.let { arrayOf(it) }
3322
+ ?: emptyArray()
3323
+ return CacheStatus(
3324
+ enabled = lookaheadConfig.enabled,
3325
+ currentSizeMb = currentSizeBytes.toDouble() / LookaheadCache.BYTES_PER_MB.toDouble(),
3326
+ maxSizeMb = cacheMaxSizeMbFrom(config),
3327
+ tracksFullyCached = tracksFullyCached.toDouble(),
3328
+ currentlyCaching = currentlyCaching
3329
+ )
3330
+ }
3331
+
3332
+ /** Fire the cache-status listeners with a fresh snapshot. Called on main. */
3333
+ private fun emitCacheStatus() {
3334
+ if (cacheStatusListeners.isEmpty) return
3335
+ val snapshot = buildCacheStatusSnapshot()
3336
+ cacheStatusListeners.forEach { it(snapshot) }
3337
+ }
3338
+
3339
+ /**
3340
+ * Hop to main + emit a cache-status snapshot. Bound to the writer's
3341
+ * `onTrackProcessed` hook so post-download status updates are
3342
+ * always dispatched on main even though the writer's coroutine
3343
+ * runs on Dispatchers.IO.
3344
+ */
3345
+ private fun onMainPostStatusEmit() {
3346
+ mainHandler.post { emitCacheStatus() }
3347
+ }
3348
+
3349
+ // --- Events — observer wiring + dispatch
3350
+
3351
+ /**
3352
+ * Wire this TrackPlayer as the [PlaybackEngine] delegate. The
3353
+ * engine owns the underlying `Player.Listener`; its callbacks
3354
+ * route through [PlaybackEngineDelegate] into the same notify
3355
+ * methods the legacy direct-listener path used. Idempotent —
3356
+ * a previously-registered delegate is cleared first. Called from
3357
+ * [configureInternal] AFTER the binder is validated.
3358
+ */
3359
+ @MainThread
3360
+ private fun installEventObservers() {
3361
+ tearDownEventObservers()
3362
+ val binder = serviceBinder
3363
+ ?: error("installEventObservers called before PlaybackService binder is ready")
3364
+ val engine = binder.engine
3365
+ engine.delegate = this
3366
+ engineDelegateRegistration = engine
3367
+ // A configure() during an active useProgress subscription routes
3368
+ // through tearDownEventObservers above, which stops the periodic
3369
+ // tick. The subscriber is still registered but no new
3370
+ // onProgress-register edge will fire to restart it. Restart here
3371
+ // when subscribers are present so progress UI keeps ticking
3372
+ // across reconfigure.
3373
+ startProgressTickIfNeeded()
3374
+ }
3375
+
3376
+ private fun tearDownEventObservers() {
3377
+ // Clear the delegate from the engine TrackPlayer last wired
3378
+ // itself onto, even if [serviceBinder] has been re-bound to a
3379
+ // fresh engine since.
3380
+ engineDelegateRegistration?.let { engine ->
3381
+ if (engine.delegate === this) engine.delegate = null
3382
+ }
3383
+ engineDelegateRegistration = null
3384
+ stopProgressTick()
3385
+ }
3386
+
3387
+ // --- PlaybackEngineDelegate — engine signal routing ---
3388
+
3389
+ override fun onStateMaybeChanged(engine: PlaybackEngine, playWhenReadyReason: Int?) {
3390
+ if (playWhenReadyReason != null) {
3391
+ // Stamp the translated reason BEFORE [emitStateIfChanged]
3392
+ // reads it. The stamp is unconditional (not a conditional
3393
+ // overlay) because a stale stash from an earlier transition
3394
+ // — e.g. INTERRUPTION from a focus loss — would otherwise
3395
+ // mislabel the following USER-initiated resume.
3396
+ pendingStateChangeReason = mapPlayWhenReadyReason(playWhenReadyReason)
3397
+ }
3398
+ emitStateIfChanged()
3399
+ // Buffer state rides the same Media3 state transitions (STATE_BUFFERING ↔
3400
+ // READY), so recompute here for sub-progress-tick latency.
3401
+ recomputeBufferState()
3402
+ // Queue-end detection lives on [onPlaybackEnded] — firing it
3403
+ // here would invoke [queueEndListeners] twice on a natural
3404
+ // STATE_ENDED (state-emit dedup masks the redundant emit, but
3405
+ // the listener fan-out is unconditional).
3406
+ }
3407
+
3408
+ override fun onIsPlayingChanged(engine: PlaybackEngine, isPlaying: Boolean) {
3409
+ if (BuildConfig.DEBUG && isPlaying && gaplessLogLastChangedAtNs > 0L) {
3410
+ val now = System.nanoTime()
3411
+ val deltaMs = (now - gaplessLogLastChangedAtNs).toDouble() / 1_000_000.0
3412
+ Log.d(
3413
+ GAPLESS_LOG_TAG,
3414
+ "playing trackIdx=$currentTrackIndex ts_ns=$now delta_since_changed_ms=${"%.3f".format(deltaMs)}"
3415
+ )
3416
+ gaplessLogLastChangedAtNs = 0L
3417
+ }
3418
+ }
3419
+
3420
+ override fun onTrackTransition(engine: PlaybackEngine, platformReason: Int) {
3421
+ handleMediaItemTransition(platformReason)
3422
+ refreshNowPlayingFormatForActiveItem()
3423
+ }
3424
+
3425
+ override fun onActiveItemFormatChanged(engine: PlaybackEngine) {
3426
+ refreshNowPlayingFormatForActiveItem()
3427
+ }
3428
+
3429
+ override fun onError(engine: PlaybackEngine, exception: PlaybackException) {
3430
+ handlePlayerError(exception)
3431
+ }
3432
+
3433
+ override fun onPlaybackEnded(engine: PlaybackEngine) {
3434
+ val ms = player ?: return
3435
+ maybeEmitQueueEnd(ms.playbackState)
3436
+ }
3437
+
3438
+ override fun onCrossfadeBegin(engine: PlaybackEngine, incomingIndex: Int) {
3439
+ // Fade-start: the standby leg is now audibly playing (volume 0
3440
+ // ramping up). Flip JS-facing active track + lock-screen / Now
3441
+ // Playing surface to the incoming track immediately so the
3442
+ // user sees the new track's title/artwork the moment they
3443
+ // start hearing it. The leading leg continues fading out for
3444
+ // the configured `crossfadeDurationMs`; engine accessors
3445
+ // already prefer the standby leg while `fadeJob` is active.
3446
+ //
3447
+ // Reason is hardcoded `AUTO_ADVANCE` — fade-start is always an
3448
+ // auto-advance event; any user-driven transition (skipToNext /
3449
+ // skipToIndex / setMediaItems) would have called `cancelFade`
3450
+ // first, so a stashed `pendingTrackChangeReason` here is stale
3451
+ // and should not re-attribute the fade.
3452
+ if (incomingIndex !in tracks.indices) return
3453
+ currentTrackIndex = incomingIndex
3454
+ lastErrorKey = null
3455
+ val track = tracks[incomingIndex]
3456
+ val cache = lookaheadCache
3457
+ currentTrackSource = classifyTrackSource(track.url) { url ->
3458
+ cache?.isFullyCached(url) == true
3459
+ }
3460
+ crossfadeFadeStartEmittedIdx = incomingIndex
3461
+ pendingTrackChangeReason = null
3462
+ // New active track => new milestone playthrough (the matching
3463
+ // structural transition echo is deduped in handleMediaItemTransition).
3464
+ milestoneTracker.reset()
3465
+ trackChangeListeners.forEach {
3466
+ it(track, incomingIndex.toDouble(), TrackChangeReason.AUTO_ADVANCE, null)
3467
+ }
3468
+ refreshNowPlayingFormatForActiveItem()
3469
+ recomputeCapabilities()
3470
+ rescheduleLookahead()
3471
+ }
3472
+
3473
+ override fun onCrossfadeCancel(engine: PlaybackEngine, revertedIndex: Int) {
3474
+ // Fade-cancelled mid-flight (pause / stop / focus loss / queue
3475
+ // mutation). JS already received `onTrackChange(incoming,
3476
+ // reason: AUTO_ADVANCE)` from the prior fade-start fire; the
3477
+ // incoming track is no longer the active one. Revert
3478
+ // `currentTrackIndex` to the leading-leg index and emit
3479
+ // `onTrackChange(leading, reason: CROSSFADE_CANCELLED)` so
3480
+ // consumer UI can rewind to the leading track.
3481
+ if (revertedIndex !in tracks.indices) return
3482
+ currentTrackIndex = revertedIndex
3483
+ lastErrorKey = null
3484
+ crossfadeFadeStartEmittedIdx = null
3485
+ val track = tracks[revertedIndex]
3486
+ val cache = lookaheadCache
3487
+ currentTrackSource = classifyTrackSource(track.url) { url ->
3488
+ cache?.isFullyCached(url) == true
3489
+ }
3490
+ pendingTrackChangeReason = null
3491
+ trackChangeListeners.forEach {
3492
+ it(track, revertedIndex.toDouble(), TrackChangeReason.CROSSFADE_CANCELLED, null)
3493
+ }
3494
+ refreshNowPlayingFormatForActiveItem()
3495
+ recomputeCapabilities()
3496
+ rescheduleLookahead()
3497
+ }
3498
+
3499
+ override fun onAutoTransitionDiscontinuity(engine: PlaybackEngine) {
3500
+ val now = System.nanoTime()
3501
+ gaplessGapPrevEndedAtNs = now
3502
+ if (BuildConfig.DEBUG) {
3503
+ gaplessLogLastEndedAtNs = now
3504
+ Log.d(
3505
+ GAPLESS_LOG_TAG,
3506
+ "item-ended trackIdx=$currentTrackIndex ts_ns=$now"
3507
+ )
3508
+ }
3509
+ }
3510
+
3511
+
3512
+ /**
3513
+ * Translate Media3 playback state + playWhenReady + suppression
3514
+ * reason into our [PlayerState] enum and fire [stateChangeListeners]
3515
+ * when the value actually changed. Consumes
3516
+ * [pendingStateChangeReason] when the emitted state is terminal
3517
+ * (PLAYING / PAUSED / ENDED / ERROR); leaves it pending across
3518
+ * intermediate BUFFERING fires so a user-initiated transition
3519
+ * labels the final state correctly.
3520
+ */
3521
+ @VisibleForTesting
3522
+ @MainThread
3523
+ internal fun emitStateIfChanged() {
3524
+ val computed = getStateInternal()
3525
+ val reason = if (computed == PlayerState.BUFFERING) {
3526
+ // Intermediate state — pass as SYSTEM and preserve the
3527
+ // pending reason for the terminal emit.
3528
+ StateChangeReason.SYSTEM
3529
+ } else if (pendingSleepTimerPause && computed == PlayerState.PAUSED) {
3530
+ // The sleep-timer fire paused playback; label this PAUSED emit
3531
+ // 'sleep-timer' regardless of the USER_REQUEST reason Media3 stamped.
3532
+ // Gated on PAUSED so a not-yet-consumed flag can never mislabel a
3533
+ // later PLAYING emit.
3534
+ StateChangeReason.SLEEP_TIMER
3535
+ } else {
3536
+ pendingStateChangeReason ?: StateChangeReason.SYSTEM
3537
+ }
3538
+ val fired = emitState(computed, reason)
3539
+ // Clear the pending reason only when the emit actually fired
3540
+ // AND the fired state was terminal (not BUFFERING). A dedup'd
3541
+ // emit must NOT consume the pending reason: the listener
3542
+ // didn't see it, so the next state change is still the user's
3543
+ // turn to attribute.
3544
+ if (fired && computed != PlayerState.BUFFERING) {
3545
+ pendingStateChangeReason = null
3546
+ pendingSleepTimerPause = false
3547
+ }
3548
+ }
3549
+
3550
+ /**
3551
+ * Single source of dedup truth for state emits. Returns true when
3552
+ * the listener actually fired (state changed since the last emit),
3553
+ * false when dedup'd. Used directly by terminal states (ERROR,
3554
+ * QUEUE_END) and indirectly via [emitStateIfChanged].
3555
+ */
3556
+ private fun emitState(state: PlayerState, reason: StateChangeReason): Boolean {
3557
+ if (state == lastReportedState) return false
3558
+ lastReportedState = state
3559
+ // Once the track actually plays, a later STATE_BUFFERING is a mid-playback
3560
+ // `stalled`, not the initial `buffering` load.
3561
+ if (state == PlayerState.PLAYING) hasStartedPlaying = true
3562
+ stateChangeListeners.forEach { it(state, reason) }
3563
+ return true
3564
+ }
3565
+
3566
+ /**
3567
+ * Cast event bridge entry point. The cast-side observer
3568
+ * [com.margelo.nitro.queueplayer.cast.CastEventBridge] calls this
3569
+ * when a remote session's state changes. Same dedup as the local
3570
+ * [emitState] path — JS sees one event per real transition,
3571
+ * regardless of which engine produced it.
3572
+ */
3573
+ internal fun emitRemoteState(state: PlayerState, reason: StateChangeReason) {
3574
+ emitState(state, reason)
3575
+ }
3576
+
3577
+ /**
3578
+ * Cast event bridge entry point for receiver-driven track changes.
3579
+ * Updates the canonical [currentTrackIndex] (so `getCurrentTrackIndex`
3580
+ * and the active-track seed reflect the receiver) and fires
3581
+ * [trackChangeListeners], so the JS now-playing surface follows the
3582
+ * receiver's queue position as it auto-advances or is skipped.
3583
+ */
3584
+ internal fun emitRemoteTrackChange(index: Int) {
3585
+ if (index < 0 || index >= tracks.size) return
3586
+ if (index == currentTrackIndex) return
3587
+ currentTrackIndex = index
3588
+ val track = tracks[index]
3589
+ trackChangeListeners.forEach { it(track, index.toDouble(), TrackChangeReason.AUTO_ADVANCE, null) }
3590
+ }
3591
+
3592
+ /**
3593
+ * Queue-end detection: fires [queueEndListeners] + emits ENDED
3594
+ * with reason QUEUE_END when the player hits `STATE_ENDED`
3595
+ * naturally (no repeat-mode wrapping). Mirrors the iOS
3596
+ * `handleCurrentItemDidChange` queue-end branch.
3597
+ */
3598
+ @VisibleForTesting
3599
+ internal fun maybeEmitQueueEnd(playbackState: Int) {
3600
+ if (playbackState != Player.STATE_ENDED) return
3601
+ if (tracks.isEmpty()) return
3602
+ queueEndListeners.forEach { it() }
3603
+ emitState(PlayerState.ENDED, StateChangeReason.QUEUE_END)
3604
+ }
3605
+
3606
+ /**
3607
+ * Handle a Media3 currentMediaItem transition: sync
3608
+ * [currentTrackIndex] from ExoPlayer's `currentMediaItemIndex`,
3609
+ * fire [trackChangeListeners] with the translated reason.
3610
+ *
3611
+ * Transition reasons from Media3:
3612
+ * - REASON_AUTO → [TrackChangeReason.AUTO_ADVANCE] (natural end
3613
+ * of previous item + next item in queue)
3614
+ * - REASON_REPEAT → [TrackChangeReason.AUTO_ADVANCE] (track
3615
+ * loop under REPEAT_MODE_ONE)
3616
+ * - REASON_SEEK → consume [pendingTrackChangeReason] if set
3617
+ * (user-skip), else fallback to [TrackChangeReason.SEEK]
3618
+ * - REASON_PLAYLIST_CHANGED → [TrackChangeReason.QUEUE_REPLACED]
3619
+ */
3620
+ @VisibleForTesting
3621
+ internal fun handleMediaItemTransition(reason: Int) {
3622
+ val p = player ?: return
3623
+ val translated = translateTransitionReason(reason, p)
3624
+
3625
+ // Natural track change → reset error dedup so a new item's
3626
+ // identical-coded error fires. Without this, a TIMEOUT on
3627
+ // track 2 (after a TIMEOUT on track 1) would be silently
3628
+ // suppressed even though they're independent failures. Mirrors
3629
+ // iOS reset in `handleCurrentItemDidChange`.
3630
+ lastErrorKey = null
3631
+ val track = if (currentTrackIndex in tracks.indices) tracks[currentTrackIndex] else null
3632
+ val cache = lookaheadCache
3633
+ currentTrackSource = classifyTrackSource(track?.url) { url ->
3634
+ cache?.isFullyCached(url) == true
3635
+ }
3636
+
3637
+ // Crossfade post-swap echo: fade-start already fired
3638
+ // `onCrossfadeBegin` which dispatched the JS-facing track-
3639
+ // change for this incoming index. The structural Media3
3640
+ // transition that lands a moment later (after `swapRoles`
3641
+ // re-binds the wrapper) carries the same index — skip the
3642
+ // duplicate listener fire. Capabilities already up-to-date
3643
+ // from fade-start; recomputeCapabilities below is a no-op.
3644
+ val skipListenerFire =
3645
+ translated == TrackChangeReason.AUTO_ADVANCE &&
3646
+ crossfadeFadeStartEmittedIdx == currentTrackIndex
3647
+ crossfadeFadeStartEmittedIdx = null
3648
+ // Measure + consume the gapless auto-advance silence gap. The end
3649
+ // timestamp is cleared on ANY auto-advance (even a deduped crossfade
3650
+ // echo) so a stale value can never leak into the next real transition;
3651
+ // the measured value only rides the emit below, which is null for every
3652
+ // non-gapless-auto-advance transition.
3653
+ var transitionGapMs: Double? = null
3654
+ if (translated == TrackChangeReason.AUTO_ADVANCE && gaplessGapPrevEndedAtNs > 0L) {
3655
+ val now = System.nanoTime()
3656
+ val gapMs = (now - gaplessGapPrevEndedAtNs).toDouble() / 1_000_000.0
3657
+ transitionGapMs = gapMs
3658
+ gaplessGapPrevEndedAtNs = 0L
3659
+ if (BuildConfig.DEBUG) {
3660
+ gaplessLogLastChangedAtNs = now
3661
+ Log.d(
3662
+ GAPLESS_LOG_TAG,
3663
+ "item-changed trackIdx=$currentTrackIndex ts_ns=$now delta_since_ended_ms=${"%.3f".format(gapMs)}"
3664
+ )
3665
+ }
3666
+ }
3667
+ if (!skipListenerFire) {
3668
+ // Real track change (incl. a repeat-one loop, reason REPEAT) =>
3669
+ // new milestone playthrough. The crossfade echo (skipListenerFire)
3670
+ // already reset at onCrossfadeBegin, so it's excluded here.
3671
+ milestoneTracker.reset()
3672
+ // New track => its initial load counts as `buffering` (not `stalled`)
3673
+ // until it reaches a playing state, and it starts not-fully-buffered.
3674
+ hasStartedPlaying = false
3675
+ recomputeFullyBuffered()
3676
+ trackChangeListeners.forEach { it(track, currentTrackIndex.toDouble(), translated, transitionGapMs) }
3677
+ }
3678
+
3679
+ // Native auto-advance (REASON_AUTO / REASON_REPEAT) shifts
3680
+ // currentTrackIndex via syncCurrentTrackIndexFromPlayer above
3681
+ // without any explicit mutation method being called. Recompute
3682
+ // capabilities here so the boundary-flip canSkipNext under OFF
3683
+ // (last-track auto-advance leaving cur on the last track with
3684
+ // no next) lands. PLAYLIST_CHANGED / SEEK funnel through their
3685
+ // originating mutation/skip body which already recomputes.
3686
+ recomputeCapabilities()
3687
+ }
3688
+
3689
+ /**
3690
+ * Translate Media3's `MEDIA_ITEM_TRANSITION_REASON_*` into our
3691
+ * [TrackChangeReason] domain enum, applying the state-sync policy:
3692
+ * `currentTrackIndex` is overridden from `p.currentMediaItemIndex`
3693
+ * only on transitions Media3 originates (AUTO / REPEAT), never on
3694
+ * caller-driven transitions (PLAYLIST_CHANGED + SEEK already wrote
3695
+ * the index via the mutation/skip arithmetic). Consumes any
3696
+ * `pendingTrackChangeReason` stashed by the originating mutation
3697
+ * body. AUTO triggers a lookahead reschedule because the index
3698
+ * shifted; REPEAT_MODE_ONE leaves the index identical so the
3699
+ * window is unchanged.
3700
+ */
3701
+ private fun translateTransitionReason(
3702
+ reason: Int, p: ExoPlayer
3703
+ ): TrackChangeReason = when (reason) {
3704
+ Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> {
3705
+ syncCurrentTrackIndexFromPlayer(p)
3706
+ rescheduleLookahead()
3707
+ TrackChangeReason.AUTO_ADVANCE
3708
+ }
3709
+ Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> {
3710
+ syncCurrentTrackIndexFromPlayer(p)
3711
+ TrackChangeReason.AUTO_ADVANCE
3712
+ }
3713
+ Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> {
3714
+ val stashed = pendingTrackChangeReason
3715
+ pendingTrackChangeReason = null
3716
+ stashed ?: TrackChangeReason.SEEK
3717
+ }
3718
+ Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> {
3719
+ // Do NOT sync from player. ExoPlayer's currentMediaItemIndex
3720
+ // after a timeline rebuild is not necessarily the logically
3721
+ // correct value (e.g. add-into-empty-queue contract keeps cur
3722
+ // at -1, but ExoPlayer will roll to 0).
3723
+ val stashed = pendingTrackChangeReason
3724
+ pendingTrackChangeReason = null
3725
+ stashed ?: TrackChangeReason.QUEUE_REPLACED
3726
+ }
3727
+ else -> TrackChangeReason.AUTO_ADVANCE
3728
+ }
3729
+
3730
+ /**
3731
+ * reschedule the proactive prefetcher after a
3732
+ * queue/skip mutation. Reads `tracks` + `currentTrackIndex` (both
3733
+ * @Volatile) and dispatches the next N remote tracks to
3734
+ * [LookaheadCacheWriter]. No-op when the writer hasn't been built
3735
+ * (configure not yet called).
3736
+ */
3737
+ @MainThread
3738
+ private fun rescheduleLookahead() {
3739
+ val writer = lookaheadCacheWriter ?: return
3740
+ writer.reschedule(tracks, currentTrackIndex)
3741
+ }
3742
+
3743
+ private fun syncCurrentTrackIndexFromPlayer(p: ExoPlayer) {
3744
+ val playerIndex = p.currentMediaItemIndex
3745
+ currentTrackIndex = if (playerIndex in tracks.indices) playerIndex else -1
3746
+ }
3747
+
3748
+ /**
3749
+ * Translate [PlaybackException] into a [PlaybackError] + emit on
3750
+ * both [errorListeners] and as a terminal ERROR state. Mirrors iOS
3751
+ * `handlePlayerItemFailedToPlayToEndTime` — transient network
3752
+ * conditions surface as non-fatal so consumers can offer retry
3753
+ * UX; codec / decoding errors surface as fatal.
3754
+ */
3755
+ private fun handlePlayerError(error: PlaybackException) {
3756
+ val mapped = PlaybackErrorMapping.classify(error)
3757
+ // Dedup by (currentTrackIndex, standardized code). Mirrors iOS
3758
+ // `(queueItemId, code)` dedup. ExoPlayer can fire onPlayerError
3759
+ // multiple times for a single failed item under retry storms.
3760
+ val key = currentTrackIndex to mapped
3761
+ if (lastErrorKey == key) return
3762
+ lastErrorKey = key
3763
+ // Auto-retry path: transient errors with retries left
3764
+ // schedule a delayed `prepare()`. Skip the JS error emit + state
3765
+ // ERROR transition until retries exhausted; consumer sees onError
3766
+ // only when the lib has given up. Mirrors iOS path.
3767
+ val idx = currentTrackIndex
3768
+ if (idx >= 0 &&
3769
+ PlaybackErrorMapping.isTransient(mapped) &&
3770
+ (retryAttemptsRemaining[idx] ?: 0) > 0) {
3771
+ retryAttemptsRemaining[idx] = retryAttemptsRemaining[idx]!! - 1
3772
+ // Clear dedup so the retry-then-fail (if any) fires fresh.
3773
+ lastErrorKey = null
3774
+ val backoff = effectiveRetryBackoffMs()
3775
+ mainHandler.postDelayed({ retryFailedItem(idx) }, backoff.toLong())
3776
+ return
3777
+ }
3778
+ // Surface the failing track's URL so consumers can correlate the
3779
+ // error to the specific item even after Media3 advances past it.
3780
+ // queueItemId stays empty on Android — the concept is iOS-specific
3781
+ // (AVPlayerItem associated-object identity); Media3 has no
3782
+ // equivalent native handle. URL is the diagnostic field that
3783
+ // matters cross-platform.
3784
+ val failedUrl = if (idx in tracks.indices) tracks[idx].url else ""
3785
+ val err = PlaybackErrorMapping.buildPlaybackError(
3786
+ error, mapped, queueItemId = "", url = failedUrl,
3787
+ )
3788
+ errorListeners.forEach { it(err) }
3789
+ emitState(PlayerState.ERROR, StateChangeReason.ERROR)
3790
+ }
3791
+
3792
+ // --- Auto-retry + stuck-recovery
3793
+
3794
+ /** Lib-clamped auto-retry budget. Reads from `config.autoRetries`,
3795
+ * defaults to 3, clamps to [0, 5]. */
3796
+ @VisibleForTesting
3797
+ internal fun effectiveAutoRetries(): Int =
3798
+ (config.autoRetries?.toInt() ?: 3).coerceIn(0, 5)
3799
+
3800
+ /** Lib-clamped retry backoff in milliseconds. Defaults to 500,
3801
+ * clamps to [200, 5000]. Floor at 200ms prevents retry storms
3802
+ * against misbehaving servers (e.g. 503 with `Retry-After: 0`). */
3803
+ @VisibleForTesting
3804
+ internal fun effectiveRetryBackoffMs(): Int =
3805
+ (config.retryBackoffMs?.toInt() ?: 500).coerceIn(200, 5000)
3806
+
3807
+ /** Try to recover from a transient failure by re-preparing the
3808
+ * ExoPlayer at the current position. Called from
3809
+ * [handlePlayerError] (auto-retry) AND from [play] (one-shot
3810
+ * stuck-recovery). Mirrors iOS `retryFailedItem` which does a
3811
+ * full AVQueuePlayer rebuild via `fullRebuildPlayerQueue`.
3812
+ *
3813
+ * Implementation: `setMediaItem(currentMediaItem, false) +
3814
+ * prepare()` rather than bare `prepare()` — the latter re-uses
3815
+ * the existing wedged DataSource on a "Cannot Open"-class
3816
+ * failure and may not actually recover. Re-setting the
3817
+ * MediaItem forces Media3 to construct a fresh DataSource from
3818
+ * the MediaSource.Factory (carries the current `httpFactory`
3819
+ * + cache wrapper). `resetPosition=false` preserves the
3820
+ * current playback position; iOS rebuild resets to 0 (mid-play
3821
+ * failure loses position regardless). For initial-load failures
3822
+ * the position is 0 anyway, so the position-preservation here
3823
+ * is the only intentional iOS/Android divergence. */
3824
+ internal fun retryFailedItem(atIndex: Int) {
3825
+ val p = player ?: return
3826
+ // Verify the failed item is still the current one. This also bounds the
3827
+ // rebuild to the leading leg's active item: mid-fade the leading leg is
3828
+ // the OUTGOING track, whose index differs from currentTrackIndex (the
3829
+ // fade-flipped incoming track), so a retry mid-fade is skipped here.
3830
+ if (atIndex != currentTrackIndex) return
3831
+ val current = p.currentMediaItem
3832
+ if (current != null) {
3833
+ val idx = p.currentMediaItemIndex
3834
+ val resumeMs = p.currentPosition.coerceAtLeast(0L)
3835
+ // Force a FRESH MediaSource/DataSource for the failed item without
3836
+ // collapsing the rest of the timeline. `replaceMediaItem(idx, current)`
3837
+ // with the SAME uri does an in-place update that RE-USES the wedged
3838
+ // HttpDataSource (the very thing the retry exists to discard);
3839
+ // `setMediaItem(item)` drops the whole queue (breaking auto-advance +
3840
+ // the crossfade full-queue look-ahead model). remove + re-add rebuilds
3841
+ // the single source from the MediaSource.Factory and keeps every other
3842
+ // item in place.
3843
+ p.removeMediaItem(idx)
3844
+ p.addMediaItem(idx, current)
3845
+ // remove + add moves the selection off `idx`; re-select it and restore
3846
+ // the pre-failure position before re-preparing from STATE_IDLE.
3847
+ p.seekTo(idx, resumeMs)
3848
+ p.prepare()
3849
+ } else {
3850
+ p.prepare()
3851
+ }
3852
+ // Resume playback if the user had been playing pre-failure.
3853
+ // (Live-state check; the pre-failure playWhenReady wasn't
3854
+ // captured)
3855
+ if (!p.isPlaying) serviceBinder?.engine?.play()
3856
+ }
3857
+
3858
+ /**
3859
+ * Translate a Media3 `PLAY_WHEN_READY_CHANGE_REASON_*` value into
3860
+ * the library's [StateChangeReason]. Returns a total result
3861
+ * (never null) so the listener can unconditionally overwrite
3862
+ * [pendingStateChangeReason] — otherwise a stale stash from an
3863
+ * earlier transition (e.g. INTERRUPTION from a focus loss)
3864
+ * would mislabel a subsequent USER-initiated resume.
3865
+ */
3866
+ @VisibleForTesting
3867
+ internal fun mapPlayWhenReadyReason(reason: Int): StateChangeReason = when (reason) {
3868
+ Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS ->
3869
+ StateChangeReason.INTERRUPTION
3870
+ Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY ->
3871
+ StateChangeReason.ROUTE_CHANGE
3872
+ Player.PLAY_WHEN_READY_CHANGE_REASON_END_OF_MEDIA_ITEM ->
3873
+ // End-of-queue variant — the subsequent maybeEmitQueueEnd
3874
+ // fires QUEUE_END as a terminal state; the SYSTEM stash is
3875
+ // the correct in-transit reason until then.
3876
+ StateChangeReason.SYSTEM
3877
+ Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST ->
3878
+ // Canonical user intent. Transport bodies stash USER before
3879
+ // calling player.play()/pause()/stop(); redundant write here
3880
+ // is harmless, and covers the edge case where Media3 fires
3881
+ // USER_REQUEST for a path that did NOT go through our
3882
+ // transport.
3883
+ StateChangeReason.USER
3884
+ Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE ->
3885
+ // Default to SYSTEM so consumers get a neutral non-user
3886
+ // reason rather than leaving a stale stash in place.
3887
+ StateChangeReason.SYSTEM
3888
+ else -> StateChangeReason.SYSTEM
3889
+ }
3890
+
3891
+ // --- Events — progress tick
3892
+
3893
+ /**
3894
+ * Start the 500 ms periodic progress ticker if a subscriber
3895
+ * exists and the ticker isn't already running. Mirrors iOS
3896
+ * `addPeriodicTimeObserver` at 2 Hz. A native `Handler` (not a JS
3897
+ * `setInterval`) is load-bearing for backgrounded playback: the
3898
+ * foreground playback service keeps the process — and this
3899
+ * app-scoped `ReactHost` — alive, so the Handler keeps ticking and
3900
+ * events keep dispatching into JS while the app is backgrounded,
3901
+ * with no separate headless keepalive task.
3902
+ */
3903
+ private fun startProgressTickIfNeeded() {
3904
+ if (progressTickActive) return
3905
+ // The tick drives progress emission, milestone detection, and fully-buffered
3906
+ // tracking, so a subscriber to any of those keeps it alive.
3907
+ if (progressListeners.isEmpty && milestoneListeners.isEmpty && fullyBufferedListeners.isEmpty) return
3908
+ progressTickActive = true
3909
+ val runnable = object : Runnable {
3910
+ override fun run() {
3911
+ if (!progressTickActive) return
3912
+ emitProgressTick()
3913
+ processMilestones()
3914
+ recomputeFullyBuffered()
3915
+ mainHandler.postDelayed(this, PROGRESS_TICK_INTERVAL_MS)
3916
+ }
3917
+ }
3918
+ progressTickRunnable = runnable
3919
+ mainHandler.postDelayed(runnable, PROGRESS_TICK_INTERVAL_MS)
3920
+ }
3921
+
3922
+ private fun stopProgressTick() {
3923
+ progressTickActive = false
3924
+ progressTickRunnable?.let { mainHandler.removeCallbacks(it) }
3925
+ progressTickRunnable = null
3926
+ }
3927
+
3928
+ // Stop the tick only when nothing needs it (no progress / milestone /
3929
+ // fully-buffered subscribers).
3930
+ private fun maybeStopProgressTick() {
3931
+ if (progressListeners.isEmpty && milestoneListeners.isEmpty && fullyBufferedListeners.isEmpty) {
3932
+ stopProgressTick()
3933
+ }
3934
+ }
3935
+
3936
+ // Foreground onProgress cadence in ms. Reads config.progressUpdateIntervalMs,
3937
+ // defaults to 500, floors at the 500ms native tick rate, ignores non-finite /
3938
+ // non-positive inputs.
3939
+ private fun effectiveForegroundProgressIntervalMs(): Double {
3940
+ val raw = config.progressUpdateIntervalMs
3941
+ if (raw == null || !raw.isFinite() || raw <= 0.0) return PROGRESS_TICK_INTERVAL_MS.toDouble()
3942
+ return maxOf(PROGRESS_TICK_INTERVAL_MS.toDouble(), raw)
3943
+ }
3944
+
3945
+ // Background onProgress cadence in ms. Reads
3946
+ // config.backgroundProgressUpdateIntervalMs, defaults to the effective
3947
+ // foreground value (no background reduction), floors at 500, ignores
3948
+ // non-finite / non-positive inputs.
3949
+ private fun effectiveBackgroundProgressIntervalMs(): Double {
3950
+ val foreground = effectiveForegroundProgressIntervalMs()
3951
+ val raw = config.backgroundProgressUpdateIntervalMs
3952
+ if (raw == null || !raw.isFinite() || raw <= 0.0) return foreground
3953
+ return maxOf(PROGRESS_TICK_INTERVAL_MS.toDouble(), raw)
3954
+ }
3955
+
3956
+ // Push the configured foreground + background cadences into the throttle.
3957
+ // Called on every configureInternal(); the gate preserves its runtime state.
3958
+ private fun applyProgressEmissionIntervals() {
3959
+ progressEmissionGate.setIntervals(
3960
+ foregroundMs = effectiveForegroundProgressIntervalMs(),
3961
+ backgroundMs = effectiveBackgroundProgressIntervalMs(),
3962
+ samplerPeriodMs = PROGRESS_TICK_INTERVAL_MS.toDouble()
3963
+ )
3964
+ }
3965
+
3966
+ // Observe app background/foreground so the throttle can switch cadence.
3967
+ // Idempotent — a no-op when already installed. ProcessLifecycleOwner replays
3968
+ // onStart on register when the process is STARTED, seeding the foreground
3969
+ // state. The 700ms onStop debounce is fine here (throttle engages ~700ms
3970
+ // after backgrounding).
3971
+ private fun installProgressLifecycleObserver() {
3972
+ if (progressLifecycleObserver != null) return
3973
+ val observer = object : DefaultLifecycleObserver {
3974
+ override fun onStart(owner: LifecycleOwner) {
3975
+ progressEmissionGate.setBackgrounded(false)
3976
+ }
3977
+
3978
+ override fun onStop(owner: LifecycleOwner) {
3979
+ progressEmissionGate.setBackgrounded(true)
3980
+ }
3981
+ }
3982
+ progressLifecycleObserver = observer
3983
+ ProcessLifecycleOwner.get().lifecycle.addObserver(observer)
3984
+ }
3985
+
3986
+ // Remove the lifecycle observer registered by installProgressLifecycleObserver.
3987
+ // Idempotent.
3988
+ private fun removeProgressLifecycleObserver() {
3989
+ progressLifecycleObserver?.let { ProcessLifecycleOwner.get().lifecycle.removeObserver(it) }
3990
+ progressLifecycleObserver = null
3991
+ }
3992
+
3993
+ /**
3994
+ * One progress tick body. Reads position + duration + buffered-
3995
+ * ahead from ExoPlayer + fires every entry in [progressListeners].
3996
+ * Runs on main. Guards against a nulled-out player or no-listeners
3997
+ * state between ticks.
3998
+ */
3999
+ @VisibleForTesting
4000
+ @MainThread
4001
+ internal fun emitProgressTick() {
4002
+ if (progressListeners.isEmpty) return
4003
+ // Throttle the JS fan-out to the configured cadence. Covers all three emit
4004
+ // paths below (cast, empty-queue, local). Milestones + fully-buffered run
4005
+ // as separate calls in the tick runnable, so they stay full-rate.
4006
+ if (!progressEmissionGate.shouldEmit(SystemClock.uptimeMillis())) return
4007
+ // During an active cast session the local engine is paused, so report
4008
+ // the receiver's position + duration — the tick keeps running while
4009
+ // casting, so this is the progress source the UI follows.
4010
+ com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote()?.let { remote ->
4011
+ val posMs = remote.positionMs.value
4012
+ val durMs = remote.durationMs.value
4013
+ val rPosition = posMs / MS_PER_SECOND
4014
+ val rDuration = if (durMs <= 0L) 0.0 else durMs / MS_PER_SECOND
4015
+ val rProgress = PlayerProgress(
4016
+ position = if (rPosition.isFinite()) rPosition else 0.0,
4017
+ duration = rDuration,
4018
+ buffered = 0.0,
4019
+ )
4020
+ progressListeners.forEach { it(rProgress) }
4021
+ return
4022
+ }
4023
+ val engine = serviceBinder?.engine ?: return
4024
+ // Lib canonical state takes precedence over the engine's
4025
+ // raw position read. After `clearQueue` / queue-end ExoPlayer
4026
+ // can briefly retain the prior item's `currentPosition` and
4027
+ // `duration` until it self-transitions to `STATE_IDLE`, which
4028
+ // would surface to JS as a stale `3:47 / 3:51` even though
4029
+ // `tracks.isEmpty()` and `currentTrackIndex == -1`. Empty-
4030
+ // queue progress is `0 / 0`.
4031
+ if (tracks.isEmpty() || currentTrackIndex < 0) {
4032
+ val zero = PlayerProgress(position = 0.0, duration = 0.0, buffered = 0.0)
4033
+ progressListeners.forEach { it(zero) }
4034
+ return
4035
+ }
4036
+ // Read via the engine accessor (not `player.currentPosition`)
4037
+ // so a CrossfadeEngine in the middle of a fade reports the
4038
+ // INCOMING leg's position via `activePlayer` retargeting,
4039
+ // not the outgoing leg's near-end position.
4040
+ val positionMs = engine.currentPositionMs
4041
+ val durationMs = engine.currentDurationMs
4042
+ val bufferedMs = engine.bufferedPositionMs
4043
+
4044
+ val position = positionMs / MS_PER_SECOND
4045
+ val duration = if (durationMs == C.TIME_UNSET) 0.0 else durationMs / MS_PER_SECOND
4046
+ val buffered = if (bufferedMs == C.TIME_UNSET || bufferedMs < positionMs) {
4047
+ 0.0
4048
+ } else {
4049
+ (bufferedMs - positionMs) / MS_PER_SECOND
4050
+ }
4051
+ val safePosition = if (position.isFinite()) position else 0.0
4052
+ val progress = PlayerProgress(
4053
+ position = safePosition, duration = duration, buffered = buffered)
4054
+ progressListeners.forEach { it(progress) }
4055
+ }
4056
+
4057
+ /**
4058
+ * Advance the milestone tracker off the same periodic tick and emit any
4059
+ * 25/50/75/90% thresholds forward playback just crossed. Runs the
4060
+ * tracker even with no subscribers (so a mid-stream subscribe doesn't
4061
+ * retroactively fire already-passed thresholds); emission is gated on
4062
+ * having listeners. Tracks the local engine position — during an active
4063
+ * cast session the local engine is paused, so milestones pause too.
4064
+ */
4065
+ private fun processMilestones() {
4066
+ if (tracks.isEmpty() || currentTrackIndex < 0) return
4067
+ val engine = serviceBinder?.engine ?: return
4068
+ val positionMs = engine.currentPositionMs
4069
+ if (positionMs < 0L) return
4070
+ val positionSec = positionMs / MS_PER_SECOND
4071
+ val durationSec = effectiveMilestoneDuration(engine.currentDurationMs)
4072
+ val crossed = milestoneTracker.tick(durationSec, positionSec)
4073
+ if (crossed.isEmpty() || milestoneListeners.isEmpty) return
4074
+ warnIfReactContextGone("onPlaybackMilestone")
4075
+ val idx = currentTrackIndex.toDouble()
4076
+ for (m in crossed) {
4077
+ milestoneListeners.forEach { it(m.toDouble(), idx) }
4078
+ }
4079
+ }
4080
+
4081
+ // Dev-only canary: a native milestone is being emitted while the app-scoped
4082
+ // ReactHost has no live ReactContext (the JS runtime is gone). The foreground
4083
+ // playback service is meant to keep that host alive for as long as playback
4084
+ // continues, so this should never fire; if it does, an event was computed
4085
+ // with no live JS to receive it — a host-app ReactHost teardown the lib
4086
+ // assumes-but-can't-enforce. Diagnostic only; no production behaviour change.
4087
+ private fun warnIfReactContextGone(event: String) {
4088
+ if (!BuildConfig.DEBUG) return
4089
+ val host = (boundContext as? ReactApplication)?.reactHost
4090
+ if (host?.currentReactContext == null) {
4091
+ Log.w(PLAYER_LOG_TAG, "native event '$event' fired with no live ReactContext")
4092
+ }
4093
+ }
4094
+
4095
+ /**
4096
+ * Engine-reported duration when known (> 0), else the consumer-supplied
4097
+ * [TrackItem.duration] (seconds) for the active track, else 0 (no
4098
+ * computable duration -> no milestones).
4099
+ */
4100
+ private fun effectiveMilestoneDuration(durationMs: Long): Double {
4101
+ // Keep the TIME_UNSET check: the engine accessors normalize unknown
4102
+ // duration to 0L (so the tick path only ever passes 0), but the seek
4103
+ // path passes a literal `?: C.TIME_UNSET` when the engine is null.
4104
+ if (durationMs != C.TIME_UNSET && durationMs > 0L) return durationMs / MS_PER_SECOND
4105
+ val td = tracks.getOrNull(currentTrackIndex)?.duration
4106
+ return if (td != null && td > 0.0) td else 0.0
4107
+ }
4108
+
4109
+ // --- Events — helpers
4110
+
4111
+ /** Synchronously run [body] on the main thread. Used for callback
4112
+ * storage — those calls aren't Promise-wrapped and arrive from
4113
+ * whatever thread Nitro's sync-function dispatcher uses. */
4114
+ private fun onMainSync(body: () -> Unit) {
4115
+ if (Looper.myLooper() == Looper.getMainLooper()) {
4116
+ body()
4117
+ } else {
4118
+ val latch = CountDownLatch(1)
4119
+ var err: Throwable? = null
4120
+ mainHandler.post {
4121
+ try { body() } catch (t: Throwable) { err = t }
4122
+ latch.countDown()
4123
+ }
4124
+ latch.await()
4125
+ err?.let { throw it }
4126
+ }
4127
+ }
4128
+
4129
+ /**
4130
+ * Test hook: stash a [TrackChangeReason] that the next
4131
+ * [handleMediaItemTransition] with `REASON_SEEK` will consume.
4132
+ * Production code reaches this field via `skipTo*Internal`
4133
+ * bodies; the hook just lets Robolectric tests simulate the
4134
+ * stash without driving a real skip + ExoPlayer seek.
4135
+ */
4136
+ @VisibleForTesting
4137
+ internal fun setPendingTrackChangeReasonForTest(reason: TrackChangeReason?) {
4138
+ pendingTrackChangeReason = reason
4139
+ }
4140
+
4141
+ /**
4142
+ * Test hook: reset the [lastReportedState] dedup edge so
4143
+ * [emitStateIfChanged] tests can force an emit from an already-
4144
+ * NONE state without going through a real Player.Listener fire.
4145
+ */
4146
+ @VisibleForTesting
4147
+ internal fun resetLastReportedStateForTest() {
4148
+ lastReportedState = PlayerState.NONE
4149
+ }
4150
+
4151
+ companion object {
4152
+ /** Progress tick cadence — matches iOS 2 Hz. */
4153
+ private const val PROGRESS_TICK_INTERVAL_MS = 500L
4154
+ /** Sleep-timer countdown/fade cadence — 500ms gives a smooth 10s fade. */
4155
+ private const val SLEEP_TIMER_TICK_INTERVAL_MS = 500L
4156
+
4157
+ /**
4158
+ * Disposer returned from `on*Request` registrations when the
4159
+ * `PlaybackService` isn't bound yet. Calling it is a no-op —
4160
+ * the registration never landed, so there's nothing to clear.
4161
+ */
4162
+ private val NO_OP_DISPOSER: () -> Unit = {}
4163
+
4164
+ /** Tolerance for "bufferedPosition has reached duration" — a CBR-estimated
4165
+ * duration or rounding can leave a sub-second gap when fully downloaded. */
4166
+ private const val FULLY_BUFFERED_EPSILON_MS = 1000L
4167
+
4168
+ /** Conversion factor between milliseconds and seconds. Centralises
4169
+ * the literal so position / duration / buffered translations all
4170
+ * reference one named constant. */
4171
+ private const val MS_PER_SECOND = 1000.0
4172
+
4173
+ /**
4174
+ * Tag for the verbose gapless-transition logs (item-ended /
4175
+ * item-changed / playing). All emit sites are guarded by
4176
+ * `BuildConfig.DEBUG` so Release builds are silent. Mirrors the
4177
+ * iOS `[RNQP-GAPLESS]` NSLog convention.
4178
+ */
4179
+ private const val GAPLESS_LOG_TAG = "RNQP-GAPLESS"
4180
+
4181
+ /** Logcat tag for player-level lifecycle warnings. Part of the
4182
+ * `QueuePlayer.<area>` hierarchy used across the lib's Android
4183
+ * sources. */
4184
+ private const val PLAYER_LOG_TAG = "QueuePlayer"
4185
+
4186
+ /**
4187
+ * Most-recently-constructed [TrackPlayer] instance. Set in
4188
+ * [configureInternal], cleared in [destroyInternal]. Read by
4189
+ * [PlaybackService] when an automotive controller's voice-search
4190
+ * resolves to a track list, so the service can sync the lib's
4191
+ * authoritative `tracks` state before Media3's framework call to
4192
+ * `player.setMediaItems` lands. Mirrors the
4193
+ * `Equalizer.current` / `Visualizer.current` accessor pattern.
4194
+ */
4195
+ @JvmStatic
4196
+ @Volatile
4197
+ var current: TrackPlayer? = null
4198
+ private set
4199
+
4200
+ /**
4201
+ * Classify the active-track playback source. `null` when the URL
4202
+ * is null or its scheme is neither `file://` nor `http(s)://`
4203
+ * (e.g. `data:`, `blob:`, custom schemes — the lib has no contract
4204
+ * to honour for those today). `http(s)://` URLs split on whether
4205
+ * `isUrlCached` returns true ([TrackSource.CACHED]) or false
4206
+ * ([TrackSource.STREAMING]). `isUrlCached` is invoked at most once,
4207
+ * only for `http(s)://` URLs.
4208
+ */
4209
+ @JvmStatic
4210
+ internal fun classifyTrackSource(
4211
+ url: String?, isUrlCached: (String) -> Boolean
4212
+ ): TrackSource? {
4213
+ if (url == null) return null
4214
+ if (url.startsWith("file://")) return TrackSource.LOCAL
4215
+ val isHttp = url.startsWith("http://") || url.startsWith("https://")
4216
+ if (!isHttp) return null
4217
+ if (isUrlCached(url)) return TrackSource.CACHED
4218
+ return TrackSource.STREAMING
4219
+ }
4220
+
4221
+ /**
4222
+ * Decide which [ServiceReadyReason] (if any) to dispatch on a
4223
+ * fresh service-bind. Pure function so the reason-decision logic
4224
+ * is unit-testable without spinning up a service controller.
4225
+ *
4226
+ * - First-ever bind ([prior] == null): [ServiceReadyReason.FIRST_BIND].
4227
+ * - Bind against a different binder identity:
4228
+ * [ServiceReadyReason.SERVICE_REBORN] — the OS killed +
4229
+ * re-created the service.
4230
+ * - Bind against the same binder identity (e.g. ServiceConnection
4231
+ * redelivery): `null` — no dispatch.
4232
+ */
4233
+ @JvmStatic
4234
+ internal fun decideReadyReason(
4235
+ prior: java.util.UUID?, current: java.util.UUID
4236
+ ): ServiceReadyReason? = when {
4237
+ prior == null -> ServiceReadyReason.FIRST_BIND
4238
+ prior != current -> ServiceReadyReason.SERVICE_REBORN
4239
+ else -> null
4240
+ }
4241
+ }
4242
+ }