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