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.
- package/LICENSE +201 -0
- package/QueuePlayer.podspec +114 -0
- package/README.md +194 -0
- package/android/CMakeLists.txt +41 -0
- package/android/build.gradle +257 -0
- package/android/src/androidTest/java/com/margelo/nitro/queueplayer/SignalsmithStretchNativeTest.kt +136 -0
- package/android/src/main/AndroidManifest.xml +173 -0
- package/android/src/main/cpp/CMakeLists.txt +221 -0
- package/android/src/main/cpp/THIRD_PARTY_LICENSES +128 -0
- package/android/src/main/cpp/THIRD_PARTY_VERSIONS.txt +32 -0
- package/android/src/main/cpp/airplay2_audio.c +537 -0
- package/android/src/main/cpp/airplay2_audio.h +66 -0
- package/android/src/main/cpp/airplay2_control.c +680 -0
- package/android/src/main/cpp/airplay2_control.h +101 -0
- package/android/src/main/cpp/airplay2_crypto.c +548 -0
- package/android/src/main/cpp/airplay2_crypto.h +213 -0
- package/android/src/main/cpp/airplay2_dmap.c +81 -0
- package/android/src/main/cpp/airplay2_dmap.h +53 -0
- package/android/src/main/cpp/airplay2_filelog.h +100 -0
- package/android/src/main/cpp/airplay2_jni.cpp +579 -0
- package/android/src/main/cpp/airplay2_ntp.c +203 -0
- package/android/src/main/cpp/airplay2_ntp.h +39 -0
- package/android/src/main/cpp/airplay2_pair.c +653 -0
- package/android/src/main/cpp/airplay2_pair.h +114 -0
- package/android/src/main/cpp/airplay2_plist.c +840 -0
- package/android/src/main/cpp/airplay2_plist.h +184 -0
- package/android/src/main/cpp/airplay2_ptp.c +509 -0
- package/android/src/main/cpp/airplay2_ptp.h +40 -0
- package/android/src/main/cpp/airplay2_rtsp.c +693 -0
- package/android/src/main/cpp/airplay2_rtsp.h +90 -0
- package/android/src/main/cpp/airplay2_session.c +196 -0
- package/android/src/main/cpp/airplay2_session.h +197 -0
- package/android/src/main/cpp/airplay2_tlv.c +135 -0
- package/android/src/main/cpp/airplay2_tlv.h +87 -0
- package/android/src/main/cpp/airplay_jni.cpp +589 -0
- package/android/src/main/cpp/build-openssl-android.sh +94 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/cpp/prebuilt/libsodium/arm64-v8a/lib/libsodium.a +0 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/core.h +28 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aegis128l.h +92 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aegis256.h +92 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_aes256gcm.h +179 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_chacha20poly1305.h +180 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_aead_xchacha20poly1305.h +100 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth.h +46 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha256.h +70 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha512.h +68 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_auth_hmacsha512256.h +65 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box.h +177 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box_curve25519xchacha20poly1305.h +164 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_box_curve25519xsalsa20poly1305.h +112 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_ed25519.h +104 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_hchacha20.h +36 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_hsalsa20.h +36 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_ristretto255.h +100 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa20.h +36 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa2012.h +36 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_core_salsa208.h +40 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_generichash.h +84 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_generichash_blake2b.h +122 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash.h +40 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash_sha256.h +60 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_hash_sha512.h +60 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf.h +53 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_blake2b.h +44 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_hkdf_sha256.h +74 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kdf_hkdf_sha512.h +75 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_kx.h +66 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_onetimeauth.h +65 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_onetimeauth_poly1305.h +72 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash.h +147 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_argon2i.h +122 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_argon2id.h +122 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_pwhash_scryptsalsa208sha256.h +120 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult.h +46 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_curve25519.h +42 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_ed25519.h +51 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_scalarmult_ristretto255.h +43 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox.h +93 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox_xchacha20poly1305.h +70 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretbox_xsalsa20poly1305.h +69 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_secretstream_xchacha20poly1305.h +108 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_shorthash.h +41 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_shorthash_siphash24.h +50 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign.h +107 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign_ed25519.h +124 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_sign_edwards25519sha512batch.h +55 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream.h +59 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_chacha20.h +106 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa20.h +61 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa2012.h +53 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_salsa208.h +56 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_xchacha20.h +61 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_stream_xsalsa20.h +61 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_16.h +23 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_32.h +23 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/crypto_verify_64.h +23 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/export.h +57 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes.h +72 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes_internal_random.h +22 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/randombytes_sysrandom.h +19 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/runtime.h +55 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/utils.h +179 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium/version.h +33 -0
- package/android/src/main/cpp/prebuilt/libsodium/include/sodium.h +75 -0
- package/android/src/main/cpp/prebuilt/libsodium/x86_64/lib/libsodium.a +0 -0
- package/android/src/main/cpp/prebuilt/openssl/arm64-v8a/lib/libcrypto.a +0 -0
- package/android/src/main/cpp/prebuilt/openssl/arm64-v8a/lib/libssl.a +0 -0
- package/android/src/main/cpp/prebuilt/openssl/x86_64/lib/libcrypto.a +0 -0
- package/android/src/main/cpp/prebuilt/openssl/x86_64/lib/libssl.a +0 -0
- package/android/src/main/cpp/stretch_jni.cpp +171 -0
- package/android/src/main/cpp/third_party/alac/addons/alac_wrapper.cpp +198 -0
- package/android/src/main/cpp/third_party/alac/addons/alac_wrapper.h +46 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACAudioTypes.h +197 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACBitUtilities.c +260 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACBitUtilities.h +104 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACDecoder.cpp +730 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACDecoder.h +65 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACEncoder.cpp +1425 -0
- package/android/src/main/cpp/third_party/alac/codec/ALACEncoder.h +92 -0
- package/android/src/main/cpp/third_party/alac/codec/EndianPortable.c +181 -0
- package/android/src/main/cpp/third_party/alac/codec/EndianPortable.h +59 -0
- package/android/src/main/cpp/third_party/alac/codec/ag_dec.c +362 -0
- package/android/src/main/cpp/third_party/alac/codec/ag_enc.c +370 -0
- package/android/src/main/cpp/third_party/alac/codec/aglib.h +81 -0
- package/android/src/main/cpp/third_party/alac/codec/dp_dec.c +381 -0
- package/android/src/main/cpp/third_party/alac/codec/dp_enc.c +386 -0
- package/android/src/main/cpp/third_party/alac/codec/dplib.h +61 -0
- package/android/src/main/cpp/third_party/alac/codec/matrix_dec.c +390 -0
- package/android/src/main/cpp/third_party/alac/codec/matrix_enc.c +342 -0
- package/android/src/main/cpp/third_party/alac/codec/matrixlib.h +80 -0
- package/android/src/main/cpp/third_party/crosstools/cross_log.c +95 -0
- package/android/src/main/cpp/third_party/crosstools/cross_log.h +25 -0
- package/android/src/main/cpp/third_party/crosstools/cross_net.c +1139 -0
- package/android/src/main/cpp/third_party/crosstools/cross_net.h +51 -0
- package/android/src/main/cpp/third_party/crosstools/cross_ssl.c +371 -0
- package/android/src/main/cpp/third_party/crosstools/cross_ssl.h +15 -0
- package/android/src/main/cpp/third_party/crosstools/cross_thread.c +134 -0
- package/android/src/main/cpp/third_party/crosstools/cross_thread.h +20 -0
- package/android/src/main/cpp/third_party/crosstools/cross_util.c +753 -0
- package/android/src/main/cpp/third_party/crosstools/cross_util.h +125 -0
- package/android/src/main/cpp/third_party/crosstools/platform.c +227 -0
- package/android/src/main/cpp/third_party/crosstools/platform.h +127 -0
- package/android/src/main/cpp/third_party/curve25519/include/curve25519_dh.h +53 -0
- package/android/src/main/cpp/third_party/curve25519/include/ed25519_signature.h +98 -0
- package/android/src/main/cpp/third_party/curve25519/include/external_calls.h +36 -0
- package/android/src/main/cpp/third_party/curve25519/source/BaseTypes.h +125 -0
- package/android/src/main/cpp/third_party/curve25519/source/base_folding8.h +1288 -0
- package/android/src/main/cpp/third_party/curve25519/source/curve25519_dh.c +208 -0
- package/android/src/main/cpp/third_party/curve25519/source/curve25519_mehdi.c +410 -0
- package/android/src/main/cpp/third_party/curve25519/source/curve25519_mehdi.h +175 -0
- package/android/src/main/cpp/third_party/curve25519/source/curve25519_order.c +155 -0
- package/android/src/main/cpp/third_party/curve25519/source/curve25519_utils.c +153 -0
- package/android/src/main/cpp/third_party/curve25519/source/custom_blind.c +27 -0
- package/android/src/main/cpp/third_party/curve25519/source/custom_blind.h +11 -0
- package/android/src/main/cpp/third_party/curve25519/source/ed25519_sign.c +419 -0
- package/android/src/main/cpp/third_party/curve25519/source/ed25519_signature.h +98 -0
- package/android/src/main/cpp/third_party/curve25519/source/ed25519_verify.c +313 -0
- package/android/src/main/cpp/third_party/curve25519/source/sha512.c +294 -0
- package/android/src/main/cpp/third_party/curve25519/source/sha512.h +93 -0
- package/android/src/main/cpp/third_party/dmap-parser/dmap_parser.c +545 -0
- package/android/src/main/cpp/third_party/dmap-parser/dmap_parser.h +90 -0
- package/android/src/main/cpp/third_party/libraop/aes.c +863 -0
- package/android/src/main/cpp/third_party/libraop/aes.h +18 -0
- package/android/src/main/cpp/third_party/libraop/aes_ctr.c +89 -0
- package/android/src/main/cpp/third_party/libraop/aes_ctr.h +59 -0
- package/android/src/main/cpp/third_party/libraop/alac.c +1135 -0
- package/android/src/main/cpp/third_party/libraop/alac.h +55 -0
- package/android/src/main/cpp/third_party/libraop/raop_client.c +1500 -0
- package/android/src/main/cpp/third_party/libraop/raop_client.h +178 -0
- package/android/src/main/cpp/third_party/libraop/rtsp_client.c +743 -0
- package/android/src/main/cpp/third_party/libraop/rtsp_client.h +50 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/__DECC_INCLUDE_EPILOGUE.H +22 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/__DECC_INCLUDE_PROLOGUE.H +26 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/aes.h +111 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asn1.h +1134 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asn1.h.in +967 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asn1err.h +142 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asn1t.h +946 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asn1t.h.in +923 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/async.h +104 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/asyncerr.h +29 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/bio.h +1022 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/bio.h.in +999 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/bioerr.h +72 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/blowfish.h +78 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/bn.h +590 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/bnerr.h +47 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/buffer.h +62 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/buffererr.h +25 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/byteorder.h +339 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/camellia.h +117 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cast.h +71 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmac.h +52 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmp.h +727 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmp.h.in +584 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmp_util.h +56 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmperr.h +134 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cms.h +511 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cms.h.in +413 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cmserr.h +126 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/comp.h +98 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/comp.h.in +76 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/comperr.h +38 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/conf.h +214 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/conf.h.in +177 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/conf_api.h +46 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/conferr.h +52 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/configuration.h +212 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/configuration.h.in +75 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/conftypes.h +44 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/core.h +236 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/core_dispatch.h +1141 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/core_names.h +575 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/core_names.h.in +126 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/core_object.h +41 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/crmf.h +278 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/crmf.h.in +207 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/crmferr.h +57 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/crypto.h +583 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/crypto.h.in +560 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cryptoerr.h +56 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cryptoerr_legacy.h +1466 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ct.h +573 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ct.h.in +525 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/cterr.h +45 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/decoder.h +133 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/decodererr.h +28 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/des.h +211 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/dh.h +339 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/dherr.h +59 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/dsa.h +280 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/dsaerr.h +44 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/dtls1.h +57 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/e_os2.h +310 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/e_ostime.h +38 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ebcdic.h +39 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ec.h +1588 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ecdh.h +10 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ecdsa.h +10 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ecerr.h +104 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/encoder.h +124 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/encodererr.h +28 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/engine.h +833 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/engineerr.h +63 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/err.h +512 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/err.h.in +501 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ess.h +128 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ess.h.in +81 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/esserr.h +32 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/evp.h +2310 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/evperr.h +142 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/fips_names.h +50 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/fipskey.h +41 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/fipskey.h.in +40 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/hmac.h +62 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/hpke.h +169 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/http.h +119 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/httperr.h +56 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/idea.h +82 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/indicator.h +31 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/kdf.h +138 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/kdferr.h +16 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/lhash.h +398 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/lhash.h.in +373 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/macros.h +349 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/md2.h +56 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/md4.h +63 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/md5.h +62 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/mdc2.h +55 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ml_kem.h +31 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/modes.h +219 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/obj_mac.h +6636 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/objects.h +184 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/objectserr.h +28 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ocsp.h +483 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ocsp.h.in +387 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ocsperr.h +53 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/opensslconf.h +17 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/opensslv.h +114 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/opensslv.h.in +113 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ossl_typ.h +16 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/param_build.h +63 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/params.h +163 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pem.h +547 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pem2.h +19 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pemerr.h +59 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12.h +366 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12.h.in +343 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs12err.h +46 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7.h +430 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7.h.in +359 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/pkcs7err.h +63 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/prov_ssl.h +38 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/proverr.h +169 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/provider.h +94 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/quic.h +75 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rand.h +131 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/randerr.h +70 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rc2.h +68 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rc4.h +47 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rc5.h +79 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ripemd.h +59 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rsa.h +615 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/rsaerr.h +107 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/safestack.h +297 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/safestack.h.in +227 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/seed.h +113 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/self_test.h +112 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/sha.h +139 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/srp.h +285 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/srp.h.in +214 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/srtp.h +68 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ssl.h +2933 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ssl.h.in +2886 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ssl2.h +30 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ssl3.h +358 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/sslerr.h +382 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/sslerr_legacy.h +467 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/stack.h +90 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/store.h +377 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/storeerr.h +49 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/symhacks.h +39 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/thread.h +31 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/tls1.h +1220 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/trace.h +321 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ts.h +522 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/tserr.h +67 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/txt_db.h +63 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/types.h +248 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ui.h +407 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/ui.h.in +384 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/uierr.h +38 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/whrlpool.h +62 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509.h +1303 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509.h.in +1109 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509_acert.h +294 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509_acert.h.in +199 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509_vfy.h +903 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509_vfy.h.in +806 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509err.h +70 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3.h +1968 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3.h.in +1367 -0
- package/android/src/main/cpp/third_party/openssl/include/openssl/x509v3err.h +97 -0
- package/android/src/main/cpp/third_party/pair_ap/pair-internal.h +390 -0
- package/android/src/main/cpp/third_party/pair_ap/pair-tlv.c +221 -0
- package/android/src/main/cpp/third_party/pair_ap/pair-tlv.h +77 -0
- package/android/src/main/cpp/third_party/pair_ap/pair.c +806 -0
- package/android/src/main/cpp/third_party/pair_ap/pair.h +266 -0
- package/android/src/main/cpp/third_party/pair_ap/pair_fruit_stub.c +36 -0
- package/android/src/main/cpp/third_party/pair_ap/pair_homekit.c +3190 -0
- package/android/src/main/cpp/third_party/signalsmith/LICENSE-signalsmith-linear.txt +21 -0
- package/android/src/main/cpp/third_party/signalsmith/LICENSE-signalsmith-stretch.txt +21 -0
- package/android/src/main/cpp/third_party/signalsmith/THIRD_PARTY_VERSIONS.txt +9 -0
- package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/fft.h +1380 -0
- package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/linear.h +1075 -0
- package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-linear/stft.h +643 -0
- package/android/src/main/cpp/third_party/signalsmith/include/signalsmith-stretch.h +1060 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +314 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ArtworkContentProvider.kt +124 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/AudioFocusErrorMapping.kt +89 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactory.kt +60 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistry.kt +136 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/BrowseDataProvider.kt +128 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +35 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CacheMimeTypes.kt +79 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CastManager.kt +629 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +1350 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeForwardingPlayer.kt +66 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CustomPresetStore.kt +100 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +348 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerEngine.kt +222 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngine.kt +213 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/EqualizerPresets.kt +45 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/FFTProcessorTee.kt +173 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/FifoCacheEvictor.kt +96 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +587 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +220 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/IEqualizerEngine.kt +97 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ListenerRegistry.kt +59 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +343 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +533 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaButtonDispatch.kt +88 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +281 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemMapper.kt +76 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +66 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +64 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MultiplexingAudioBufferSink.kt +92 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +37 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractor.kt +166 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingBrowseRequests.kt +70 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PendingIntentBuffer.kt +82 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChain.kt +69 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PitchCorrection.kt +27 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +75 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +336 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +175 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachine.kt +127 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +1716 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +755 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +70 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +131 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueuePlayerPackage.kt +22 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +113 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/RadixTwoFFT.kt +176 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +81 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessor.kt +84 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainConfig.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainData.kt +44 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainExtractor.kt +209 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/ReplayGainGain.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SearchResultCache.kt +72 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +167 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchAudioProcessor.kt +225 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SignalsmithStretchNative.kt +93 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +125 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +4242 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +70 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/UrlSchemes.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/VisualizationGate.kt +35 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/Visualizer.kt +240 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/VisualizerTeeBuilder.kt +63 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/VoiceSearchExtrasParser.kt +103 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/AudioRouteState.kt +202 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/AuthRequiredException.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackend.kt +41 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastBackends.kt +56 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +122 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastMediaItem.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +193 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSinkRouter.kt +95 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +151 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/DiscoveryState.kt +53 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/MetadataAwareSession.kt +17 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/PlaybackStateRouter.kt +65 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/Receiver.kt +35 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2JNI.kt +129 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +189 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +123 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Sink.kt +205 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackend.kt +585 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayBackendInitializer.kt +43 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscovery.kt +511 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscoveryLifecycleObserver.kt +57 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +199 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayReceiverMapper.kt +42 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayRenderersFactory.kt +43 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +101 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySink.kt +265 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayTxtParser.kt +203 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI.kt +106 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MulticastLockHolder.kt +75 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/CastMediaItemConverter.kt +67 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/CastVolumeForwardingPlayer.kt +156 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastBackend.kt +171 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastOptionsProvider.kt +51 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastReceiverMapper.kt +44 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +544 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/MediaInfoBuilder.kt +92 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/RemoteMediaCallbacks.kt +113 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServer.kt +160 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/LocalMediaServerLifecycle.kt +15 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaServerHandle.kt +44 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MediaTokenRegistry.kt +37 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/http/MimeTypes.kt +27 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/net/LocalAddressMonitor.kt +86 -0
- package/android/src/main/res/raw/rnqp_placeholder.jpg +0 -0
- package/android/src/main/res/values/cast_strings.xml +15 -0
- package/android/src/main/res/values/strings.xml +24 -0
- package/android/src/main/res/xml/automotive_app_desc.xml +15 -0
- package/android/src/test/java/androidx/media3/session/MediaSessionControllerRequestTestSeam.kt +27 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ArtworkContentProviderTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/AudioFocusErrorMappingTest.kt +122 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +57 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +264 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +104 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseDataProviderTest.kt +276 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/BufferStateTranslatorTest.kt +58 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CacheMimeTypesTest.kt +116 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +214 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +180 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/CustomPresetStoreTest.kt +128 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +223 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +274 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +214 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerPresetsTest.kt +89 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +185 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/FifoCacheEvictorTest.kt +112 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ForwardingPlayerTestAccess.kt +21 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +65 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +154 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +106 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +183 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheWriterTest.kt +649 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +192 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +389 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemMapperTest.kt +145 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +81 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MultiplexingAudioBufferSinkTest.kt +112 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/NotificationProviderTest.kt +86 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +106 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingBrowseRequestsTest.kt +82 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PendingIntentBufferTest.kt +192 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +55 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +187 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +134 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackModeStateMachineTest.kt +157 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +95 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +480 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +171 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +773 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCastBindTest.kt +100 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +315 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceSessionActivityTest.kt +121 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +246 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +113 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueMutationArithmeticTest.kt +252 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +343 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RadixTwoFFTTest.kt +109 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RemoteCommandDeduperTest.kt +106 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainAudioProcessorTest.kt +141 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainConfigTest.kt +103 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainExtractorTest.kt +469 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ReplayGainGainTest.kt +129 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +83 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SearchResultCacheTest.kt +128 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ServiceReadyReasonTest.kt +42 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +521 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SleepTimerCoreTest.kt +125 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SmokeTest.kt +27 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +305 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +179 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +497 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +209 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +417 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +473 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +61 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +28 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +89 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +290 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +151 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +69 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +324 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +287 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSourceClassifierTest.kt +82 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +222 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TransportStateTranslatorTest.kt +287 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizationGateTest.kt +96 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +202 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/VoiceSearchExtrasParserTest.kt +211 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/CastSinkRouterTest.kt +120 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +62 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/PlaybackStateRouterTest.kt +43 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayDiscoveryFilterTest.kt +86 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayTxtParserClassificationTest.kt +34 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/chromecast/CastMediaItemConverterTest.kt +78 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/chromecast/CastVolumeForwardingPlayerTest.kt +176 -0
- package/app.plugin.js +466 -0
- package/ios/AVPlayerItemQueueItemId.swift +61 -0
- package/ios/AVQueueBuilder.swift +200 -0
- package/ios/Array+SafeSubscript.swift +6 -0
- package/ios/ArtworkLoader.swift +136 -0
- package/ios/ArtworkResolver.swift +87 -0
- package/ios/AudioCategoryMapping.swift +48 -0
- package/ios/AudioSession.swift +220 -0
- package/ios/AudioSessionError.swift +44 -0
- package/ios/AudioTapProvider.swift +647 -0
- package/ios/BiquadCoefficients.swift +80 -0
- package/ios/CarPlayBridge.swift +178 -0
- package/ios/CarPlayBrowseBuilder.swift +368 -0
- package/ios/CarPlayBrowseDataProvider.swift +162 -0
- package/ios/CarPlayCoordinator.swift +42 -0
- package/ios/CarPlaySceneDelegate.swift +199 -0
- package/ios/Cast/AirPlayRouteState.swift +281 -0
- package/ios/Cast/Chromecast/ChromecastDiscoveryAdapter.swift +27 -0
- package/ios/Cast/Chromecast/ChromecastReceiverMapper.swift +48 -0
- package/ios/Cast/Chromecast/ChromecastSession.swift +740 -0
- package/ios/Cast/Chromecast/ChromecastSessionAdapter.swift +114 -0
- package/ios/Cast/Chromecast/ChromecastSupport.swift +63 -0
- package/ios/Cast/Chromecast/MediaInfoBuilder.swift +104 -0
- package/ios/Cast/Chromecast/RemoteMediaObserver.swift +147 -0
- package/ios/Cast/Core/CastEventBridge.swift +210 -0
- package/ios/Cast/Core/CastMediaItem.swift +41 -0
- package/ios/Cast/Core/CastNowPlayingController.swift +310 -0
- package/ios/Cast/Core/CastReceiverState.swift +22 -0
- package/ios/Cast/Core/CastSession.swift +147 -0
- package/ios/Cast/Core/CastTransportRouter.swift +162 -0
- package/ios/Cast/Core/ChromecastBackend.swift +259 -0
- package/ios/Cast/Core/LocalAddressMonitor.swift +110 -0
- package/ios/Cast/Core/LocalMediaServer.swift +270 -0
- package/ios/Cast/Core/LocalNetworkPermissionProbe.swift +265 -0
- package/ios/Cast/Core/MediaHTTPConnection.swift +349 -0
- package/ios/Cast/Core/MediaServerHandle.swift +49 -0
- package/ios/Cast/Core/MediaTokenRegistry.swift +51 -0
- package/ios/Cast/Core/MimeTypes.swift +26 -0
- package/ios/Cast/Core/PlaybackStateRouter.swift +123 -0
- package/ios/CastManager.swift +493 -0
- package/ios/CrossfadeEngine.swift +1343 -0
- package/ios/CustomPresetStore.swift +78 -0
- package/ios/EQProcessor.swift +321 -0
- package/ios/EQTap.swift +241 -0
- package/ios/Equalizer.swift +321 -0
- package/ios/EqualizerPresets.swift +48 -0
- package/ios/FFTProcessor.swift +237 -0
- package/ios/GaplessEngine.swift +385 -0
- package/ios/InputGuards.swift +63 -0
- package/ios/InterruptionEventMapping.swift +51 -0
- package/ios/ListenerRegistry.swift +73 -0
- package/ios/LookaheadCache.swift +833 -0
- package/ios/LookaheadCacheEviction.swift +83 -0
- package/ios/LookaheadCachePrefetcher.swift +503 -0
- package/ios/MetadataReader.swift +555 -0
- package/ios/MilestoneTracker.swift +63 -0
- package/ios/NowPlayingFormatExtractor.swift +362 -0
- package/ios/NowPlayingInfo.swift +190 -0
- package/ios/OutputRouteMonitor.swift +125 -0
- package/ios/PendingBrowseRequests.swift +62 -0
- package/ios/PitchAlgorithm.swift +24 -0
- package/ios/PlaceholderArtwork.swift +46 -0
- package/ios/PlaybackEngine.swift +319 -0
- package/ios/PlaybackErrorMapping.swift +195 -0
- package/ios/PlaybackModeStateMachine.swift +159 -0
- package/ios/PlayerStateDerivation.swift +45 -0
- package/ios/ProgressEmissionGate.swift +54 -0
- package/ios/QueueMutationArithmetic.swift +129 -0
- package/ios/QueueSkipArithmetic.swift +109 -0
- package/ios/QueueState.swift +49 -0
- package/ios/RemoteCommands.swift +229 -0
- package/ios/ReplayGainData.swift +58 -0
- package/ios/ReplayGainExtractor.swift +82 -0
- package/ios/ReplayGainProcessor.swift +46 -0
- package/ios/Resources/RNQP-placeholder.jpg +0 -0
- package/ios/Resources/silence.m4a +0 -0
- package/ios/Siri/PendingIntentBuffer.swift +100 -0
- package/ios/Siri/PlayMediaIntentHandler.swift +283 -0
- package/ios/Siri/QueuePlayerMediaIntents.swift +45 -0
- package/ios/Siri/VoiceDonation.swift +50 -0
- package/ios/SleepTimerCore.swift +128 -0
- package/ios/StreamingBitrateProbe.swift +318 -0
- package/ios/Tests/AVQueueBuilderTests.swift +430 -0
- package/ios/Tests/ArtworkResolverTests.swift +540 -0
- package/ios/Tests/AudioSessionTests.swift +222 -0
- package/ios/Tests/AudioTapProviderReplayGainTests.swift +136 -0
- package/ios/Tests/AudioTapProviderVisualizationFlagTests.swift +104 -0
- package/ios/Tests/BiquadCoefficientsTests.swift +84 -0
- package/ios/Tests/CarPlayBridgeTests.swift +91 -0
- package/ios/Tests/CarPlayBrowseBuilderTests.swift +221 -0
- package/ios/Tests/CarPlayBrowseDataProviderTests.swift +218 -0
- package/ios/Tests/CastNowPlayingControllerTests.swift +204 -0
- package/ios/Tests/CrossfadeEngineStubTests.swift +43 -0
- package/ios/Tests/EQProcessorTests.swift +302 -0
- package/ios/Tests/EQTapTests.swift +49 -0
- package/ios/Tests/EqualizerAudioMixProviderTests.swift +110 -0
- package/ios/Tests/EqualizerHybridTests.swift +303 -0
- package/ios/Tests/EqualizerPresetsTests.swift +69 -0
- package/ios/Tests/FFTProcessorTests.swift +303 -0
- package/ios/Tests/Fixtures/silence-aac-128.m4a +0 -0
- package/ios/Tests/Fixtures/silence-cbr-128.mp3 +0 -0
- package/ios/Tests/Fixtures/silence-vbr.mp3 +0 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +160 -0
- package/ios/Tests/InputGuardsTests.swift +75 -0
- package/ios/Tests/InterruptionEventMappingTests.swift +91 -0
- package/ios/Tests/LookaheadCacheEvictionTests.swift +217 -0
- package/ios/Tests/LookaheadCachePrefetcherTests.swift +630 -0
- package/ios/Tests/LookaheadCacheRuntimeConfigTests.swift +247 -0
- package/ios/Tests/LookaheadCacheTests.swift +717 -0
- package/ios/Tests/MediaHTTPConnectionTests.swift +163 -0
- package/ios/Tests/MetadataReaderTests.swift +605 -0
- package/ios/Tests/MilestoneTrackerTests.swift +76 -0
- package/ios/Tests/NowPlayingFormatExtractorTests.swift +158 -0
- package/ios/Tests/NowPlayingInfoTests.swift +292 -0
- package/ios/Tests/PendingBrowseRequestsTests.swift +69 -0
- package/ios/Tests/PendingIntentBufferTests.swift +164 -0
- package/ios/Tests/PlaybackEngineProtocolTests.swift +92 -0
- package/ios/Tests/PlaybackErrorMappingTests.swift +179 -0
- package/ios/Tests/PlaybackModeStateMachineTests.swift +40 -0
- package/ios/Tests/PlaybackStateRouterTests.swift +178 -0
- package/ios/Tests/PlayerStateDerivationTests.swift +69 -0
- package/ios/Tests/ProgressEmissionGateTests.swift +113 -0
- package/ios/Tests/QueueMutationArithmeticTests.swift +245 -0
- package/ios/Tests/RemoteCommandsTests.swift +147 -0
- package/ios/Tests/ReplayGainExtractorTests.swift +197 -0
- package/ios/Tests/ReplayGainProcessorTests.swift +90 -0
- package/ios/Tests/SkipCapabilityTests.swift +98 -0
- package/ios/Tests/SkipIndexTests.swift +259 -0
- package/ios/Tests/SleepTimerCoreTests.swift +98 -0
- package/ios/Tests/StreamingBitrateProbeTests.swift +29 -0
- package/ios/Tests/TrackSourceClassifierTests.swift +50 -0
- package/ios/Tests/VoiceDonationTests.swift +26 -0
- package/ios/TrackPlayer.swift +4766 -0
- package/ios/Visualizer.swift +274 -0
- package/ios/WindowApplicationSceneDelegate.swift +59 -0
- package/ios/tests-harness/Podfile +56 -0
- package/ios/tests-harness/TestHost/App.swift +12 -0
- package/ios/tests-harness/TestHost/Info.plist +37 -0
- package/ios/tests-harness/TestHost.xcodeproj/project.pbxproj +472 -0
- package/ios/tests-harness/TestHost.xcodeproj/xcshareddata/xcschemes/TestHost.xcscheme +68 -0
- package/ios/tests-harness/package.json +10 -0
- package/ios/tests-harness/scripts/seed-xcodeproj.rb +118 -0
- package/lib/module/CastManager.nitro.js +4 -0
- package/lib/module/CastManager.nitro.js.map +1 -0
- package/lib/module/Equalizer.nitro.js +4 -0
- package/lib/module/Equalizer.nitro.js.map +1 -0
- package/lib/module/TrackPlayer.nitro.js +4 -0
- package/lib/module/TrackPlayer.nitro.js.map +1 -0
- package/lib/module/Visualizer.nitro.js +4 -0
- package/lib/module/Visualizer.nitro.js.map +1 -0
- package/lib/module/cast/index.js +156 -0
- package/lib/module/cast/index.js.map +1 -0
- package/lib/module/clients.js +43 -0
- package/lib/module/clients.js.map +1 -0
- package/lib/module/equalizer.js +85 -0
- package/lib/module/equalizer.js.map +1 -0
- package/lib/module/hooks/index.js +16 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useActiveTrack.js +50 -0
- package/lib/module/hooks/useActiveTrack.js.map +1 -0
- package/lib/module/hooks/useAudioRoute.js +60 -0
- package/lib/module/hooks/useAudioRoute.js.map +1 -0
- package/lib/module/hooks/useBufferState.js +34 -0
- package/lib/module/hooks/useBufferState.js.map +1 -0
- package/lib/module/hooks/useCanSkip.js +67 -0
- package/lib/module/hooks/useCanSkip.js.map +1 -0
- package/lib/module/hooks/useCast.js +107 -0
- package/lib/module/hooks/useCast.js.map +1 -0
- package/lib/module/hooks/useEqualizer.js +91 -0
- package/lib/module/hooks/useEqualizer.js.map +1 -0
- package/lib/module/hooks/useFullyBuffered.js +33 -0
- package/lib/module/hooks/useFullyBuffered.js.map +1 -0
- package/lib/module/hooks/useLookaheadCache.js +49 -0
- package/lib/module/hooks/useLookaheadCache.js.map +1 -0
- package/lib/module/hooks/useNowPlayingFormat.js +42 -0
- package/lib/module/hooks/useNowPlayingFormat.js.map +1 -0
- package/lib/module/hooks/usePlaybackState.js +36 -0
- package/lib/module/hooks/usePlaybackState.js.map +1 -0
- package/lib/module/hooks/useProgress.js +31 -0
- package/lib/module/hooks/useProgress.js.map +1 -0
- package/lib/module/hooks/useSleepTimer.js +65 -0
- package/lib/module/hooks/useSleepTimer.js.map +1 -0
- package/lib/module/hooks/useVisualizerState.js +99 -0
- package/lib/module/hooks/useVisualizerState.js.map +1 -0
- package/lib/module/index.js +37 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/playbackService.js +174 -0
- package/lib/module/playbackService.js.map +1 -0
- package/lib/module/types.js +592 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/CastManager.nitro.d.ts +154 -0
- package/lib/typescript/CastManager.nitro.d.ts.map +1 -0
- package/lib/typescript/Equalizer.nitro.d.ts +30 -0
- package/lib/typescript/Equalizer.nitro.d.ts.map +1 -0
- package/lib/typescript/TrackPlayer.nitro.d.ts +584 -0
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -0
- package/lib/typescript/Visualizer.nitro.d.ts +36 -0
- package/lib/typescript/Visualizer.nitro.d.ts.map +1 -0
- package/lib/typescript/cast/index.d.ts +65 -0
- package/lib/typescript/cast/index.d.ts.map +1 -0
- package/lib/typescript/clients.d.ts +9 -0
- package/lib/typescript/clients.d.ts.map +1 -0
- package/lib/typescript/equalizer.d.ts +45 -0
- package/lib/typescript/equalizer.d.ts.map +1 -0
- package/lib/typescript/hooks/index.d.ts +22 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -0
- package/lib/typescript/hooks/useActiveTrack.d.ts +21 -0
- package/lib/typescript/hooks/useActiveTrack.d.ts.map +1 -0
- package/lib/typescript/hooks/useAudioRoute.d.ts +13 -0
- package/lib/typescript/hooks/useAudioRoute.d.ts.map +1 -0
- package/lib/typescript/hooks/useBufferState.d.ts +18 -0
- package/lib/typescript/hooks/useBufferState.d.ts.map +1 -0
- package/lib/typescript/hooks/useCanSkip.d.ts +43 -0
- package/lib/typescript/hooks/useCanSkip.d.ts.map +1 -0
- package/lib/typescript/hooks/useCast.d.ts +32 -0
- package/lib/typescript/hooks/useCast.d.ts.map +1 -0
- package/lib/typescript/hooks/useEqualizer.d.ts +20 -0
- package/lib/typescript/hooks/useEqualizer.d.ts.map +1 -0
- package/lib/typescript/hooks/useFullyBuffered.d.ts +15 -0
- package/lib/typescript/hooks/useFullyBuffered.d.ts.map +1 -0
- package/lib/typescript/hooks/useLookaheadCache.d.ts +13 -0
- package/lib/typescript/hooks/useLookaheadCache.d.ts.map +1 -0
- package/lib/typescript/hooks/useNowPlayingFormat.d.ts +18 -0
- package/lib/typescript/hooks/useNowPlayingFormat.d.ts.map +1 -0
- package/lib/typescript/hooks/usePlaybackState.d.ts +9 -0
- package/lib/typescript/hooks/usePlaybackState.d.ts.map +1 -0
- package/lib/typescript/hooks/useProgress.d.ts +15 -0
- package/lib/typescript/hooks/useProgress.d.ts.map +1 -0
- package/lib/typescript/hooks/useSleepTimer.d.ts +26 -0
- package/lib/typescript/hooks/useSleepTimer.d.ts.map +1 -0
- package/lib/typescript/hooks/useVisualizerState.d.ts +40 -0
- package/lib/typescript/hooks/useVisualizerState.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +14 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/playbackService.d.ts +140 -0
- package/lib/typescript/playbackService.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +1215 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/nitro.json +53 -0
- package/nitrogen/generated/android/c++/JAudioCategoryOptions.hpp +65 -0
- package/nitrogen/generated/android/c++/JAudioCodec.hpp +73 -0
- package/nitrogen/generated/android/c++/JAudioContainer.hpp +76 -0
- package/nitrogen/generated/android/c++/JAudioContentType.hpp +58 -0
- package/nitrogen/generated/android/c++/JAudioRoute.hpp +75 -0
- package/nitrogen/generated/android/c++/JAudioRouteKind.hpp +91 -0
- package/nitrogen/generated/android/c++/JBrowseItem.hpp +78 -0
- package/nitrogen/generated/android/c++/JBrowseSection.hpp +97 -0
- package/nitrogen/generated/android/c++/JBrowseSnapshot.hpp +87 -0
- package/nitrogen/generated/android/c++/JBufferState.hpp +64 -0
- package/nitrogen/generated/android/c++/JCacheStatus.hpp +92 -0
- package/nitrogen/generated/android/c++/JCastConfigureOptions.hpp +62 -0
- package/nitrogen/generated/android/c++/JCastConnectOptions.hpp +57 -0
- package/nitrogen/generated/android/c++/JCastDeviceKind.hpp +70 -0
- package/nitrogen/generated/android/c++/JCastDiscoveryState.hpp +57 -0
- package/nitrogen/generated/android/c++/JCastLocalNetworkPermission.hpp +61 -0
- package/nitrogen/generated/android/c++/JCastLocalNetworkPermissionEvent.hpp +58 -0
- package/nitrogen/generated/android/c++/JCastProtocol.hpp +67 -0
- package/nitrogen/generated/android/c++/JCastReceiver.hpp +89 -0
- package/nitrogen/generated/android/c++/JCastReceiverCapabilities.hpp +66 -0
- package/nitrogen/generated/android/c++/JCastRoute.hpp +85 -0
- package/nitrogen/generated/android/c++/JCastSessionDiedEvent.hpp +63 -0
- package/nitrogen/generated/android/c++/JCastSessionEndReason.hpp +76 -0
- package/nitrogen/generated/android/c++/JCastStreamingModel.hpp +58 -0
- package/nitrogen/generated/android/c++/JEqualizerBand.hpp +61 -0
- package/nitrogen/generated/android/c++/JEqualizerPreset.hpp +76 -0
- package/nitrogen/generated/android/c++/JEvictionPolicy.hpp +58 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.hpp +146 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.hpp +161 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.hpp +147 -0
- package/nitrogen/generated/android/c++/JFunc_void.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_AudioRoute.hpp +80 -0
- package/nitrogen/generated/android/c++/JFunc_void_BufferState.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_CacheStatus.hpp +79 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastDiscoveryState.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastLocalNetworkPermissionEvent.hpp +79 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastRoute.hpp +86 -0
- package/nitrogen/generated/android/c++/JFunc_void_CastSessionDiedEvent.hpp +80 -0
- package/nitrogen/generated/android/c++/JFunc_void_PlaybackError.hpp +80 -0
- package/nitrogen/generated/android/c++/JFunc_void_PlayerProgress.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_PlayerState_StateChangeReason.hpp +79 -0
- package/nitrogen/generated/android/c++/JFunc_void_ServiceReadyReason.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_SkipCapability.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_SleepTimerState.hpp +78 -0
- package/nitrogen/generated/android/c++/JFunc_void_VisualizerErrorReason.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_VisualizerFrame.hpp +80 -0
- package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.hpp +82 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.hpp +93 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_CastReceiver_.hpp +105 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_EqualizerBand_.hpp +96 -0
- package/nitrogen/generated/android/c++/JHybridCastManagerSpec.cpp +388 -0
- package/nitrogen/generated/android/c++/JHybridCastManagerSpec.hpp +84 -0
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.cpp +233 -0
- package/nitrogen/generated/android/c++/JHybridEqualizerSpec.hpp +74 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +1052 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +130 -0
- package/nitrogen/generated/android/c++/JHybridVisualizerSpec.cpp +84 -0
- package/nitrogen/generated/android/c++/JHybridVisualizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JLookaheadCacheConfig.hpp +61 -0
- package/nitrogen/generated/android/c++/JMediaSearchOrigin.hpp +58 -0
- package/nitrogen/generated/android/c++/JMediaSearchQueueLocation.hpp +61 -0
- package/nitrogen/generated/android/c++/JMediaSearchReference.hpp +58 -0
- package/nitrogen/generated/android/c++/JMediaSearchReleaseDateRange.hpp +61 -0
- package/nitrogen/generated/android/c++/JMediaSearchRepeatMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JMediaSearchRequest.hpp +126 -0
- package/nitrogen/generated/android/c++/JMediaSearchType.hpp +67 -0
- package/nitrogen/generated/android/c++/JNowPlayingFormat.hpp +123 -0
- package/nitrogen/generated/android/c++/JNowPlayingFormatAndroidExtras.hpp +71 -0
- package/nitrogen/generated/android/c++/JNowPlayingFormatIosExtras.hpp +61 -0
- package/nitrogen/generated/android/c++/JPitchCorrectionMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JPlaybackError.hpp +87 -0
- package/nitrogen/generated/android/c++/JPlaybackErrorCode.hpp +88 -0
- package/nitrogen/generated/android/c++/JPlaybackMode.hpp +63 -0
- package/nitrogen/generated/android/c++/JPlaybackModeKind.hpp +58 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +126 -0
- package/nitrogen/generated/android/c++/JPlayerProgress.hpp +65 -0
- package/nitrogen/generated/android/c++/JPlayerState.hpp +73 -0
- package/nitrogen/generated/android/c++/JRemoteControlOptions.hpp +66 -0
- package/nitrogen/generated/android/c++/JRemoteSkipMode.hpp +58 -0
- package/nitrogen/generated/android/c++/JRepeatMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JReplayGainMode.hpp +61 -0
- package/nitrogen/generated/android/c++/JSectionIcon.hpp +91 -0
- package/nitrogen/generated/android/c++/JServiceReadyReason.hpp +58 -0
- package/nitrogen/generated/android/c++/JShuffleResult.hpp +88 -0
- package/nitrogen/generated/android/c++/JSkipCapability.hpp +61 -0
- package/nitrogen/generated/android/c++/JSleepTimerState.hpp +69 -0
- package/nitrogen/generated/android/c++/JStateChangeReason.hpp +73 -0
- package/nitrogen/generated/android/c++/JTrackChangeReason.hpp +73 -0
- package/nitrogen/generated/android/c++/JTrackItem.hpp +100 -0
- package/nitrogen/generated/android/c++/JTrackSource.hpp +61 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_Double.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_Double.hpp +69 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_NowPlayingFormat.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_NowPlayingFormat.hpp +83 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_String.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_String.hpp +70 -0
- package/nitrogen/generated/android/c++/JVisualizerConfig.hpp +65 -0
- package/nitrogen/generated/android/c++/JVisualizerErrorReason.hpp +64 -0
- package/nitrogen/generated/android/c++/JVisualizerFrame.hpp +67 -0
- package/nitrogen/generated/android/c++/JVoiceVocabulary.hpp +98 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioCategoryOptions.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioCodec.kt +28 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioContainer.kt +29 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioContentType.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioRoute.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/AudioRouteKind.kt +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseItem.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseSection.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BrowseSnapshot.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/BufferState.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CacheStatus.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastConfigureOptions.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastConnectOptions.kt +51 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastDeviceKind.kt +27 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastDiscoveryState.kt +51 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastLocalNetworkPermission.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastLocalNetworkPermissionEvent.kt +51 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastProtocol.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastReceiver.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastReceiverCapabilities.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastRoute.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastSessionDiedEvent.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastSessionEndReason.kt +29 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/CastStreamingModel.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EqualizerBand.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EqualizerPreset.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/EvictionPolicy.kt +23 -0
- 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
- 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
- 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
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_AudioRoute.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_BufferState.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CacheStatus.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastDiscoveryState.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastLocalNetworkPermissionEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastRoute.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_CastSessionDiedEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlaybackError.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerProgress.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_PlayerState_StateChangeReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_ServiceReadyReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SkipCapability.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_SleepTimerState.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerErrorReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_VisualizerFrame.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_bool.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_double_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_CastReceiver_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_EqualizerBand_.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridCastManagerSpec.kt +169 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridEqualizerSpec.kt +109 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +419 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridVisualizerSpec.kt +63 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/LookaheadCacheConfig.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchOrigin.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchQueueLocation.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchReference.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchReleaseDateRange.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchRepeatMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchRequest.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/MediaSearchType.kt +26 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormat.kt +116 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormatAndroidExtras.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/NowPlayingFormatIosExtras.kt +51 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PitchCorrectionMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackError.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackErrorCode.kt +33 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackMode.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlaybackModeKind.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +111 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerProgress.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerState.kt +28 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RemoteControlOptions.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RemoteSkipMode.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/RepeatMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ReplayGainMode.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SectionIcon.kt +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ServiceReadyReason.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/ShuffleResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SkipCapability.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SleepTimerState.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/StateChangeReason.kt +28 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackChangeReason.kt +28 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackItem.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/TrackSource.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_Double.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_NowPlayingFormat.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Variant_NullType_String.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerConfig.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerErrorReason.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VisualizerFrame.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/VoiceVocabulary.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/queueplayerOnLoad.kt +35 -0
- package/nitrogen/generated/android/queueplayer+autolinking.cmake +90 -0
- package/nitrogen/generated/android/queueplayer+autolinking.gradle +27 -0
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +152 -0
- package/nitrogen/generated/android/queueplayerOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/QueuePlayer+autolinking.rb +62 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +343 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +1831 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +256 -0
- package/nitrogen/generated/ios/QueuePlayerAutolinking.mm +57 -0
- package/nitrogen/generated/ios/QueuePlayerAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridCastManagerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridCastManagerSpecSwift.hpp +298 -0
- package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridEqualizerSpecSwift.hpp +178 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +749 -0
- package/nitrogen/generated/ios/c++/HybridVisualizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridVisualizerSpecSwift.hpp +103 -0
- package/nitrogen/generated/ios/swift/AudioCategoryOptions.swift +78 -0
- package/nitrogen/generated/ios/swift/AudioCodec.swift +60 -0
- package/nitrogen/generated/ios/swift/AudioContainer.swift +64 -0
- package/nitrogen/generated/ios/swift/AudioContentType.swift +40 -0
- package/nitrogen/generated/ios/swift/AudioRoute.swift +49 -0
- package/nitrogen/generated/ios/swift/AudioRouteKind.swift +84 -0
- package/nitrogen/generated/ios/swift/BrowseItem.swift +93 -0
- package/nitrogen/generated/ios/swift/BrowseSection.swift +74 -0
- package/nitrogen/generated/ios/swift/BrowseSnapshot.swift +40 -0
- package/nitrogen/generated/ios/swift/BufferState.swift +48 -0
- package/nitrogen/generated/ios/swift/CacheStatus.swift +55 -0
- package/nitrogen/generated/ios/swift/CastConfigureOptions.swift +47 -0
- package/nitrogen/generated/ios/swift/CastConnectOptions.swift +29 -0
- package/nitrogen/generated/ios/swift/CastDeviceKind.swift +56 -0
- package/nitrogen/generated/ios/swift/CastDiscoveryState.swift +29 -0
- package/nitrogen/generated/ios/swift/CastLocalNetworkPermission.swift +44 -0
- package/nitrogen/generated/ios/swift/CastLocalNetworkPermissionEvent.swift +29 -0
- package/nitrogen/generated/ios/swift/CastProtocol.swift +52 -0
- package/nitrogen/generated/ios/swift/CastReceiver.swift +59 -0
- package/nitrogen/generated/ios/swift/CastReceiverCapabilities.swift +39 -0
- package/nitrogen/generated/ios/swift/CastRoute.swift +54 -0
- package/nitrogen/generated/ios/swift/CastSessionDiedEvent.swift +34 -0
- package/nitrogen/generated/ios/swift/CastSessionEndReason.swift +64 -0
- package/nitrogen/generated/ios/swift/CastStreamingModel.swift +40 -0
- package/nitrogen/generated/ios/swift/EqualizerBand.swift +34 -0
- package/nitrogen/generated/ios/swift/EqualizerPreset.swift +45 -0
- package/nitrogen/generated/ios/swift/EvictionPolicy.swift +40 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_BrowseItem______std__string.swift +67 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______MediaSearchRequest.swift +67 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_TrackItem______std__string.swift +67 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_AudioRoute.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_BufferState.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CacheStatus.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CastDiscoveryState.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CastLocalNetworkPermission.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CastLocalNetworkPermissionEvent.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CastRoute.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_CastSessionDiedEvent.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_PlaybackError.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_PlayerProgress.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_PlayerState_StateChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ServiceReadyReason.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ShuffleResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_SkipCapability.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_SleepTimerState.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_VisualizerErrorReason.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_VisualizerFrame.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__optional_TrackItem__double_TrackChangeReason_std__optional_double_.swift +53 -0
- package/nitrogen/generated/ios/swift/Func_void_std__optional_std__variant_nitro__NullType__NowPlayingFormat__.swift +65 -0
- package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__vector_BrowseItem___.swift +66 -0
- package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_Promise_std__vector_TrackItem___.swift +66 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_BrowseItem_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_CastReceiver_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_EqualizerBand_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem_.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridCastManagerSpec.swift +76 -0
- package/nitrogen/generated/ios/swift/HybridCastManagerSpec_cxx.swift +519 -0
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec.swift +66 -0
- package/nitrogen/generated/ios/swift/HybridEqualizerSpec_cxx.swift +353 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +122 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +1430 -0
- package/nitrogen/generated/ios/swift/HybridVisualizerSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridVisualizerSpec_cxx.swift +163 -0
- package/nitrogen/generated/ios/swift/LookaheadCacheConfig.swift +34 -0
- package/nitrogen/generated/ios/swift/MediaSearchOrigin.swift +40 -0
- package/nitrogen/generated/ios/swift/MediaSearchQueueLocation.swift +44 -0
- package/nitrogen/generated/ios/swift/MediaSearchReference.swift +40 -0
- package/nitrogen/generated/ios/swift/MediaSearchReleaseDateRange.swift +60 -0
- package/nitrogen/generated/ios/swift/MediaSearchRepeatMode.swift +44 -0
- package/nitrogen/generated/ios/swift/MediaSearchRequest.swift +239 -0
- package/nitrogen/generated/ios/swift/MediaSearchType.swift +52 -0
- package/nitrogen/generated/ios/swift/NowPlayingFormat.swift +394 -0
- package/nitrogen/generated/ios/swift/NowPlayingFormatAndroidExtras.swift +135 -0
- package/nitrogen/generated/ios/swift/NowPlayingFormatIosExtras.swift +61 -0
- package/nitrogen/generated/ios/swift/PitchCorrectionMode.swift +44 -0
- package/nitrogen/generated/ios/swift/PlaybackError.swift +64 -0
- package/nitrogen/generated/ios/swift/PlaybackErrorCode.swift +80 -0
- package/nitrogen/generated/ios/swift/PlaybackMode.swift +47 -0
- package/nitrogen/generated/ios/swift/PlaybackModeKind.swift +40 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +251 -0
- package/nitrogen/generated/ios/swift/PlayerProgress.swift +39 -0
- package/nitrogen/generated/ios/swift/PlayerState.swift +60 -0
- package/nitrogen/generated/ios/swift/RemoteControlOptions.swift +39 -0
- package/nitrogen/generated/ios/swift/RemoteSkipMode.swift +40 -0
- package/nitrogen/generated/ios/swift/RepeatMode.swift +44 -0
- package/nitrogen/generated/ios/swift/ReplayGainMode.swift +44 -0
- package/nitrogen/generated/ios/swift/SectionIcon.swift +84 -0
- package/nitrogen/generated/ios/swift/ServiceReadyReason.swift +40 -0
- package/nitrogen/generated/ios/swift/ShuffleResult.swift +51 -0
- package/nitrogen/generated/ios/swift/SkipCapability.swift +34 -0
- package/nitrogen/generated/ios/swift/SleepTimerState.swift +70 -0
- package/nitrogen/generated/ios/swift/StateChangeReason.swift +60 -0
- package/nitrogen/generated/ios/swift/TrackChangeReason.swift +60 -0
- package/nitrogen/generated/ios/swift/TrackItem.swift +169 -0
- package/nitrogen/generated/ios/swift/TrackSource.swift +44 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_Double.swift +30 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_NowPlayingFormat.swift +30 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_String.swift +30 -0
- package/nitrogen/generated/ios/swift/VisualizerConfig.swift +52 -0
- package/nitrogen/generated/ios/swift/VisualizerErrorReason.swift +48 -0
- package/nitrogen/generated/ios/swift/VisualizerFrame.swift +52 -0
- package/nitrogen/generated/ios/swift/VoiceVocabulary.swift +46 -0
- package/nitrogen/generated/shared/c++/AudioCategoryOptions.hpp +91 -0
- package/nitrogen/generated/shared/c++/AudioCodec.hpp +96 -0
- package/nitrogen/generated/shared/c++/AudioContainer.hpp +100 -0
- package/nitrogen/generated/shared/c++/AudioContentType.hpp +76 -0
- package/nitrogen/generated/shared/c++/AudioRoute.hpp +101 -0
- package/nitrogen/generated/shared/c++/AudioRouteKind.hpp +120 -0
- package/nitrogen/generated/shared/c++/BrowseItem.hpp +104 -0
- package/nitrogen/generated/shared/c++/BrowseSection.hpp +106 -0
- package/nitrogen/generated/shared/c++/BrowseSnapshot.hpp +90 -0
- package/nitrogen/generated/shared/c++/BufferState.hpp +84 -0
- package/nitrogen/generated/shared/c++/CacheStatus.hpp +100 -0
- package/nitrogen/generated/shared/c++/CastConfigureOptions.hpp +88 -0
- package/nitrogen/generated/shared/c++/CastConnectOptions.hpp +83 -0
- package/nitrogen/generated/shared/c++/CastDeviceKind.hpp +92 -0
- package/nitrogen/generated/shared/c++/CastDiscoveryState.hpp +83 -0
- package/nitrogen/generated/shared/c++/CastLocalNetworkPermission.hpp +80 -0
- package/nitrogen/generated/shared/c++/CastLocalNetworkPermissionEvent.hpp +84 -0
- package/nitrogen/generated/shared/c++/CastProtocol.hpp +88 -0
- package/nitrogen/generated/shared/c++/CastReceiver.hpp +115 -0
- package/nitrogen/generated/shared/c++/CastReceiverCapabilities.hpp +92 -0
- package/nitrogen/generated/shared/c++/CastRoute.hpp +111 -0
- package/nitrogen/generated/shared/c++/CastSessionDiedEvent.hpp +89 -0
- package/nitrogen/generated/shared/c++/CastSessionEndReason.hpp +100 -0
- package/nitrogen/generated/shared/c++/CastStreamingModel.hpp +76 -0
- package/nitrogen/generated/shared/c++/EqualizerBand.hpp +87 -0
- package/nitrogen/generated/shared/c++/EqualizerPreset.hpp +92 -0
- package/nitrogen/generated/shared/c++/EvictionPolicy.hpp +76 -0
- package/nitrogen/generated/shared/c++/HybridCastManagerSpec.cpp +42 -0
- package/nitrogen/generated/shared/c++/HybridCastManagerSpec.hpp +115 -0
- package/nitrogen/generated/shared/c++/HybridEqualizerSpec.cpp +32 -0
- package/nitrogen/generated/shared/c++/HybridEqualizerSpec.hpp +81 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +88 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +209 -0
- package/nitrogen/generated/shared/c++/HybridVisualizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridVisualizerSpec.hpp +71 -0
- package/nitrogen/generated/shared/c++/LookaheadCacheConfig.hpp +87 -0
- package/nitrogen/generated/shared/c++/MediaSearchOrigin.hpp +76 -0
- package/nitrogen/generated/shared/c++/MediaSearchQueueLocation.hpp +80 -0
- package/nitrogen/generated/shared/c++/MediaSearchReference.hpp +76 -0
- package/nitrogen/generated/shared/c++/MediaSearchReleaseDateRange.hpp +87 -0
- package/nitrogen/generated/shared/c++/MediaSearchRepeatMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/MediaSearchRequest.hpp +157 -0
- package/nitrogen/generated/shared/c++/MediaSearchType.hpp +88 -0
- package/nitrogen/generated/shared/c++/NowPlayingFormat.hpp +149 -0
- package/nitrogen/generated/shared/c++/NowPlayingFormatAndroidExtras.hpp +94 -0
- package/nitrogen/generated/shared/c++/NowPlayingFormatIosExtras.hpp +85 -0
- package/nitrogen/generated/shared/c++/PitchCorrectionMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/PlaybackError.hpp +113 -0
- package/nitrogen/generated/shared/c++/PlaybackErrorCode.hpp +116 -0
- package/nitrogen/generated/shared/c++/PlaybackMode.hpp +89 -0
- package/nitrogen/generated/shared/c++/PlaybackModeKind.hpp +76 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +141 -0
- package/nitrogen/generated/shared/c++/PlayerProgress.hpp +91 -0
- package/nitrogen/generated/shared/c++/PlayerState.hpp +96 -0
- package/nitrogen/generated/shared/c++/RemoteControlOptions.hpp +92 -0
- package/nitrogen/generated/shared/c++/RemoteSkipMode.hpp +76 -0
- package/nitrogen/generated/shared/c++/RepeatMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/ReplayGainMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/SectionIcon.hpp +120 -0
- package/nitrogen/generated/shared/c++/ServiceReadyReason.hpp +76 -0
- package/nitrogen/generated/shared/c++/ShuffleResult.hpp +94 -0
- package/nitrogen/generated/shared/c++/SkipCapability.hpp +87 -0
- package/nitrogen/generated/shared/c++/SleepTimerState.hpp +95 -0
- package/nitrogen/generated/shared/c++/StateChangeReason.hpp +96 -0
- package/nitrogen/generated/shared/c++/TrackChangeReason.hpp +96 -0
- package/nitrogen/generated/shared/c++/TrackItem.hpp +113 -0
- package/nitrogen/generated/shared/c++/TrackSource.hpp +80 -0
- package/nitrogen/generated/shared/c++/VisualizerConfig.hpp +91 -0
- package/nitrogen/generated/shared/c++/VisualizerErrorReason.hpp +84 -0
- package/nitrogen/generated/shared/c++/VisualizerFrame.hpp +92 -0
- package/nitrogen/generated/shared/c++/VoiceVocabulary.hpp +88 -0
- package/package.json +167 -0
- package/src/CastManager.nitro.ts +206 -0
- package/src/Equalizer.nitro.ts +37 -0
- package/src/TrackPlayer.nitro.ts +667 -0
- package/src/Visualizer.nitro.ts +48 -0
- package/src/cast/index.ts +224 -0
- package/src/clients.ts +50 -0
- package/src/equalizer.ts +86 -0
- package/src/hooks/index.ts +33 -0
- package/src/hooks/useActiveTrack.ts +55 -0
- package/src/hooks/useAudioRoute.ts +69 -0
- package/src/hooks/useBufferState.ts +35 -0
- package/src/hooks/useCanSkip.ts +92 -0
- package/src/hooks/useCast.ts +121 -0
- package/src/hooks/useEqualizer.ts +111 -0
- package/src/hooks/useFullyBuffered.ts +32 -0
- package/src/hooks/useLookaheadCache.ts +54 -0
- package/src/hooks/useNowPlayingFormat.ts +43 -0
- package/src/hooks/usePlaybackState.ts +34 -0
- package/src/hooks/useProgress.ts +36 -0
- package/src/hooks/useSleepTimer.ts +63 -0
- package/src/hooks/useVisualizerState.ts +121 -0
- package/src/index.ts +125 -0
- package/src/playbackService.ts +304 -0
- 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
|
+
}
|