react-native-queue-player 1.1.1 → 1.2.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/android/build.gradle +7 -0
- package/android/consumer-rules.pro +72 -3
- package/android/src/main/cpp/airplay2_jni.cpp +23 -3
- package/android/src/main/cpp/airplay_jni.cpp +12 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +31 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +19 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +6 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +26 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +6 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +162 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +122 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
- package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
- package/ios/Cast/Core/CastSession.swift +11 -0
- package/ios/GaplessEngine.swift +11 -5
- package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
- package/ios/Tests/GaplessEngineLifecycleTests.swift +26 -0
- package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
- package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
- package/ios/TrackPlayer.swift +32 -32
- package/package.json +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
package/android/build.gradle
CHANGED
|
@@ -129,6 +129,13 @@ dependencies {
|
|
|
129
129
|
implementation "com.facebook.react:react-android"
|
|
130
130
|
implementation project(":react-native-nitro-modules")
|
|
131
131
|
|
|
132
|
+
// @Keep drives R8 retention of the Nitrogen-generated bridge classes, and
|
|
133
|
+
// this artifact carries the consumer rule that honours it. Resolved
|
|
134
|
+
// transitively via Media3 / androidx.core today; declared explicitly at the
|
|
135
|
+
// version that already resolves so the dependency surface is honest rather
|
|
136
|
+
// than relying on another artifact keeping it in the graph.
|
|
137
|
+
implementation "androidx.annotation:annotation:1.8.1"
|
|
138
|
+
|
|
132
139
|
// Media3 ExoPlayer — Android audio engine. Pinned to 1.10.0 (current
|
|
133
140
|
// stable, Mar 2026). 1.10 keeps cache + audio/wake-lock APIs stable
|
|
134
141
|
// and only deprecates MetadataRetriever (we don't use it).
|
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
# React Native Queue Player — consumer R8 / ProGuard rules.
|
|
2
2
|
#
|
|
3
|
-
# Packaged into the
|
|
3
|
+
# Packaged into the AAR via `consumerProguardFiles` and applied
|
|
4
4
|
# automatically to a consuming app's R8 run, so consumers need no manual rules
|
|
5
|
-
# for this library's transitive dependencies.
|
|
5
|
+
# for this library's native bindings or its transitive dependencies.
|
|
6
|
+
#
|
|
7
|
+
# The keeps below are deliberately whole-class rather than member-scoped. The
|
|
8
|
+
# saving from narrowing them is a few kilobytes; the cost of getting one wrong
|
|
9
|
+
# is a silent runtime failure in someone else's shipped app.
|
|
10
|
+
|
|
11
|
+
# libraop's JNI_OnLoad binds this class by literal descriptor: src/main/cpp/
|
|
12
|
+
# airplay_jni.cpp calls FindClass on the string
|
|
13
|
+
# "com/margelo/nitro/queueplayer/cast/airplay/AirplayJNI", then resolves all 28
|
|
14
|
+
# entries of its JNINativeMethod table by name and signature. React Native's own
|
|
15
|
+
# rules pin the native method names but let the class be renamed, and the rule
|
|
16
|
+
# that pins the class name ships only in AGP's optional default ProGuard file.
|
|
17
|
+
# When the class is renamed, FindClass returns null, RegisterNatives never runs,
|
|
18
|
+
# and AirPlay 1 reports itself unavailable rather than failing loudly.
|
|
19
|
+
-keep class com.margelo.nitro.queueplayer.cast.airplay.AirplayJNI { *; }
|
|
20
|
+
|
|
21
|
+
# libairplay2 binds this class the same way (src/main/cpp/airplay2_jni.cpp) and
|
|
22
|
+
# additionally calls back into Kotlin: the audio drain thread invokes
|
|
23
|
+
# onNativeSessionDied(long, int), resolved through GetStaticMethodID on that
|
|
24
|
+
# literal name. Nothing in Kotlin calls that method, so without this rule R8
|
|
25
|
+
# deletes it as unreachable — the native side then logs that upcalls are
|
|
26
|
+
# disabled and carries on. Connect and streaming still work; only the receiver's
|
|
27
|
+
# feedback timeout stops arriving, so a dead receiver never tears its session
|
|
28
|
+
# down and never reverts routing to local. Playback stops with the UI still
|
|
29
|
+
# showing the remote device.
|
|
30
|
+
-keep class com.margelo.nitro.queueplayer.cast.airplay.AirPlay2JNI { *; }
|
|
31
|
+
|
|
32
|
+
# libsignalsmith_stretch has no JNI_OnLoad; src/main/cpp/stretch_jni.cpp relies
|
|
33
|
+
# on implicit JNI name mangling, so its exported symbols spell out both the
|
|
34
|
+
# package path and the method name (Java_com_margelo_nitro_queueplayer_
|
|
35
|
+
# SignalsmithStretchNative_nativeCreate). Renaming either half breaks the lookup
|
|
36
|
+
# and the `music` pitch-correction mode throws UnsatisfiedLinkError on first use.
|
|
37
|
+
# The native methods live on this class, not on its Companion.
|
|
38
|
+
-keep class com.margelo.nitro.queueplayer.SignalsmithStretchNative { *; }
|
|
39
|
+
|
|
40
|
+
# Named as a string in this library's own AndroidManifest.xml, through the cast
|
|
41
|
+
# OPTIONS_PROVIDER_CLASS_NAME meta-data. aapt2 generates keep rules for manifest
|
|
42
|
+
# components but does not descend into meta-data values, so nothing automatic
|
|
43
|
+
# protects this one. The Play Services Cast AAR happens to keep it today via
|
|
44
|
+
# `-keep public class * implements OptionsProvider`; this rule means correctness
|
|
45
|
+
# does not depend on another artifact's rule surviving.
|
|
46
|
+
-keep class com.margelo.nitro.queueplayer.cast.chromecast.ChromecastOptionsProvider { *; }
|
|
47
|
+
|
|
48
|
+
# Named as a string in this library's own AndroidManifest.xml, as the
|
|
49
|
+
# androidx.startup meta-data entry that registers it with
|
|
50
|
+
# InitializationProvider. Same shape as the cast options provider above and
|
|
51
|
+
# equally invisible to aapt2, which generates keep rules for manifest
|
|
52
|
+
# components but not for meta-data values. androidx.startup keeps it today via
|
|
53
|
+
# `-keepnames class * extends androidx.startup.Initializer`; this rule means
|
|
54
|
+
# correctness does not depend on that.
|
|
55
|
+
-keep class com.margelo.nitro.queueplayer.cast.airplay.AirPlayBackendInitializer { *; }
|
|
56
|
+
|
|
57
|
+
# The Nitrogen-generated enums are read from C++ by constant name — e.g.
|
|
58
|
+
# getStaticField<JCastProtocol>("AIRPLAY") — so losing a constant's name breaks
|
|
59
|
+
# every conversion across the bridge. They survive today only because
|
|
60
|
+
# androidx.annotation ships a keep rule for @Keep-annotated classes and Nitrogen
|
|
61
|
+
# annotates what it generates, which makes correctness depend on that artifact
|
|
62
|
+
# staying in the resolution graph. `**` also matches the enums internal to this
|
|
63
|
+
# library that native code never touches; keeping them is accepted collateral.
|
|
64
|
+
-keep enum com.margelo.nitro.queueplayer.** { *; }
|
|
65
|
+
|
|
66
|
+
# Constructed from C++ during Nitro's onLoad via getConstructor<javaobject()>().
|
|
67
|
+
# R8's compatibility FAQ states that full mode does not implicitly keep a kept
|
|
68
|
+
# class's no-arg constructor; AGP's keep-rule documentation states that it does.
|
|
69
|
+
# Current R8 keeps it. These rules make the library correct under either.
|
|
70
|
+
-keep class com.margelo.nitro.queueplayer.TrackPlayer { <init>(); }
|
|
71
|
+
-keep class com.margelo.nitro.queueplayer.Equalizer { <init>(); }
|
|
72
|
+
-keep class com.margelo.nitro.queueplayer.Visualizer { <init>(); }
|
|
73
|
+
-keep class com.margelo.nitro.queueplayer.CastManager { <init>(); }
|
|
6
74
|
|
|
7
|
-
# The embedded Ktor CIO HTTP server (the
|
|
75
|
+
# The embedded Ktor CIO HTTP server (the Chromecast local media server, which
|
|
76
|
+
# serves local files to a receiver) ships
|
|
8
77
|
# io.ktor.util.debug.IntellijIdeaDebugDetector, which references
|
|
9
78
|
# java.lang.management.ManagementFactory and RuntimeMXBean to detect an attached
|
|
10
79
|
# IntelliJ debugger. Those JMX classes are JVM-only and absent on Android, so a
|
|
@@ -546,19 +546,39 @@ jint ap2_register_natives(JavaVM *vm) {
|
|
|
546
546
|
|
|
547
547
|
jclass clazz = env->FindClass(sClassName);
|
|
548
548
|
if (!clazz) {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
549
|
+
/* Leaves a pending NoClassDefFoundError. Clear it and fail the load
|
|
550
|
+
* outright: nothing retries registration, so reporting success here
|
|
551
|
+
* would leave the Kotlin object reporting AirPlay 2 as available with
|
|
552
|
+
* no natives bound, and the first call would throw
|
|
553
|
+
* UnsatisfiedLinkError. Failing makes System.loadLibrary throw, and
|
|
554
|
+
* AirPlay2JNI's initialiser catches that and sets isAvailable false. */
|
|
555
|
+
env->ExceptionClear();
|
|
556
|
+
__android_log_print(ANDROID_LOG_ERROR, TAG,
|
|
557
|
+
"AirPlay2JNI class not found — AirPlay 2 unavailable");
|
|
558
|
+
return JNI_ERR;
|
|
552
559
|
}
|
|
553
560
|
|
|
554
561
|
if (env->RegisterNatives(clazz, sMethods,
|
|
555
562
|
sizeof(sMethods) / sizeof(sMethods[0])) < 0) {
|
|
563
|
+
/* An unresolved table entry leaves a pending NoSuchMethodError. */
|
|
564
|
+
env->ExceptionClear();
|
|
556
565
|
__android_log_print(ANDROID_LOG_ERROR, TAG, "RegisterNatives failed");
|
|
557
566
|
return JNI_ERR;
|
|
558
567
|
}
|
|
559
568
|
|
|
560
569
|
/* Cache the session-death upcall target (global ref + static method id). */
|
|
561
570
|
sAp2JniClass = (jclass)env->NewGlobalRef(clazz);
|
|
571
|
+
if (!sAp2JniClass) {
|
|
572
|
+
/* Null only when the global reference table is exhausted, which can
|
|
573
|
+
* leave a pending OutOfMemoryError; clear it so the load reports
|
|
574
|
+
* success with upcalls disabled, matching the missing-method path
|
|
575
|
+
* below. The check has to precede GetStaticMethodID, which aborts
|
|
576
|
+
* under CheckJNI when handed a null class. */
|
|
577
|
+
env->ExceptionClear();
|
|
578
|
+
__android_log_print(ANDROID_LOG_WARN, TAG,
|
|
579
|
+
"could not retain AirPlay2JNI — upcalls disabled");
|
|
580
|
+
return JNI_VERSION_1_6;
|
|
581
|
+
}
|
|
562
582
|
sOnNativeSessionDied = env->GetStaticMethodID(
|
|
563
583
|
sAp2JniClass, "onNativeSessionDied", "(JI)V");
|
|
564
584
|
if (!sOnNativeSessionDied) {
|
|
@@ -576,13 +576,22 @@ jint JNI_OnLoad(JavaVM *vm, void * /* reserved */) {
|
|
|
576
576
|
|
|
577
577
|
jclass clazz = env->FindClass(sClassName);
|
|
578
578
|
if (clazz == nullptr) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
/* Leaves a pending NoClassDefFoundError. Clear it and fail the load
|
|
580
|
+
* outright: nothing retries registration, so reporting success here
|
|
581
|
+
* would leave the Kotlin object reporting AirPlay 1 as available with
|
|
582
|
+
* no natives bound, and the first call would throw
|
|
583
|
+
* UnsatisfiedLinkError. Failing makes System.loadLibrary throw, and
|
|
584
|
+
* AirplayJNI's initialiser catches that and sets isAvailable false. */
|
|
585
|
+
env->ExceptionClear();
|
|
586
|
+
__android_log_print(ANDROID_LOG_ERROR, TAG,
|
|
587
|
+
"AirplayJNI class not found — AirPlay 1 unavailable");
|
|
588
|
+
return JNI_ERR;
|
|
582
589
|
}
|
|
583
590
|
|
|
584
591
|
if (env->RegisterNatives(clazz, sMethods,
|
|
585
592
|
sizeof(sMethods) / sizeof(sMethods[0])) < 0) {
|
|
593
|
+
/* An unresolved table entry leaves a pending NoSuchMethodError. */
|
|
594
|
+
env->ExceptionClear();
|
|
586
595
|
__android_log_print(ANDROID_LOG_ERROR, TAG, "RegisterNatives failed");
|
|
587
596
|
return JNI_ERR;
|
|
588
597
|
}
|
|
@@ -974,12 +974,19 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
974
974
|
}
|
|
975
975
|
|
|
976
976
|
override fun onSkipToNextCommand(): Boolean {
|
|
977
|
+
// Cast routing before the local body, same early-return contract as
|
|
978
|
+
// the public transport methods. The `true` return suppresses the
|
|
979
|
+
// forwarding player's default seek either way, so the receiver gets
|
|
980
|
+
// exactly one skip. The reason stamp sits on the local path because
|
|
981
|
+
// that is the only path whose listener consumes it.
|
|
982
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return true
|
|
977
983
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
978
984
|
skipToNextInternal()
|
|
979
985
|
return true
|
|
980
986
|
}
|
|
981
987
|
|
|
982
988
|
override fun onSkipToPreviousCommand(): Boolean {
|
|
989
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return true
|
|
983
990
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
984
991
|
skipToPreviousInternal()
|
|
985
992
|
return true
|
|
@@ -990,7 +997,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
990
997
|
// no-ops it (Media3 passes C.INDEX_UNSET when the seek implies
|
|
991
998
|
// no move).
|
|
992
999
|
if (mediaItemIndex < 0 || mediaItemIndex >= tracks.size) return false
|
|
993
|
-
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
994
1000
|
// Same path as the public skipToIndex: jump the receiver while
|
|
995
1001
|
// casting, else the local engine. Both write currentTrackIndex +
|
|
996
1002
|
// the USER_SKIP_TO_INDEX reason that a raw controller seek skips.
|
|
@@ -1000,21 +1006,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1000
1006
|
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(mediaItemIndex)) {
|
|
1001
1007
|
return true
|
|
1002
1008
|
}
|
|
1009
|
+
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1003
1010
|
skipToIndexInternal(mediaItemIndex)
|
|
1004
1011
|
return true
|
|
1005
1012
|
}
|
|
1006
1013
|
|
|
1007
1014
|
override fun onMediaPlay() {
|
|
1015
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePlay()) return
|
|
1008
1016
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1009
1017
|
serviceBinder?.engine?.play()
|
|
1010
1018
|
}
|
|
1011
1019
|
|
|
1012
1020
|
override fun onMediaPause() {
|
|
1021
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routePause()) return
|
|
1013
1022
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1014
1023
|
serviceBinder?.engine?.pause()
|
|
1015
1024
|
}
|
|
1016
1025
|
|
|
1017
1026
|
override fun onMediaTogglePlayPause() {
|
|
1027
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeTogglePlayPause()) return
|
|
1018
1028
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1019
1029
|
val engine = serviceBinder?.engine ?: return
|
|
1020
1030
|
// `playWhenReady` (intent to play) is read off the underlying
|
|
@@ -1026,11 +1036,13 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1026
1036
|
}
|
|
1027
1037
|
|
|
1028
1038
|
override fun onMediaSkipNext() {
|
|
1039
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToNext()) return
|
|
1029
1040
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1030
1041
|
skipToNextInternal()
|
|
1031
1042
|
}
|
|
1032
1043
|
|
|
1033
1044
|
override fun onMediaSkipPrevious() {
|
|
1045
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToPrevious()) return
|
|
1034
1046
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1035
1047
|
skipToPreviousInternal()
|
|
1036
1048
|
}
|
|
@@ -2875,13 +2887,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2875
2887
|
// goes away.
|
|
2876
2888
|
|
|
2877
2889
|
/**
|
|
2878
|
-
* Snapshot of the
|
|
2879
|
-
*
|
|
2880
|
-
*
|
|
2881
|
-
*
|
|
2890
|
+
* Snapshot of the active playback source's state mapped onto the public
|
|
2891
|
+
* [PlayerState] enum — the cast receiver while a session owns playback,
|
|
2892
|
+
* otherwise the local ExoPlayer. Safe to call from any thread: the receiver
|
|
2893
|
+
* read is lock-free, and the local read hops to main via [syncMain] so
|
|
2894
|
+
* Media3's thread-assertion does not fire.
|
|
2882
2895
|
*/
|
|
2883
|
-
override fun getState(): PlayerState
|
|
2884
|
-
|
|
2896
|
+
override fun getState(): PlayerState {
|
|
2897
|
+
// The receiver owns playback during a cast session — the local player is
|
|
2898
|
+
// parked and paused for its duration, so its derived state describes
|
|
2899
|
+
// nothing the user can hear. Until the receiver has reported (session
|
|
2900
|
+
// still activating, holding its initial IDLE) the local read is the
|
|
2901
|
+
// better answer; after that the receiver is authoritative, including a
|
|
2902
|
+
// later IDLE, which means it genuinely has nothing loaded.
|
|
2903
|
+
val remote = com.margelo.nitro.queueplayer.cast.PlaybackStateRouter.activeRemote()
|
|
2904
|
+
if (remote != null && remote.hasReportedState) {
|
|
2905
|
+
return com.margelo.nitro.queueplayer.cast.mapRemotePlaybackState(remote.state.value)
|
|
2906
|
+
}
|
|
2907
|
+
return syncMain { getStateInternal() }
|
|
2908
|
+
}
|
|
2885
2909
|
|
|
2886
2910
|
@VisibleForTesting
|
|
2887
2911
|
internal fun getStateInternal(): PlayerState {
|
|
@@ -70,7 +70,7 @@ internal class CastEventBridge(private val owner: TrackPlayer) {
|
|
|
70
70
|
sessionJob = scope.launch {
|
|
71
71
|
launch {
|
|
72
72
|
session.state.collectLatest { remoteState ->
|
|
73
|
-
owner.emitRemoteState(
|
|
73
|
+
owner.emitRemoteState(mapRemotePlaybackState(remoteState), StateChangeReason.SYSTEM)
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
launch {
|
|
@@ -110,13 +110,4 @@ internal class CastEventBridge(private val owner: TrackPlayer) {
|
|
|
110
110
|
lastPositionMs = positionMs
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
private fun mapState(state: RemotePlaybackState): PlayerState = when (state) {
|
|
114
|
-
RemotePlaybackState.PLAYING -> PlayerState.PLAYING
|
|
115
|
-
RemotePlaybackState.PAUSED -> PlayerState.PAUSED
|
|
116
|
-
RemotePlaybackState.LOADING -> PlayerState.BUFFERING
|
|
117
|
-
RemotePlaybackState.BUFFERING -> PlayerState.BUFFERING
|
|
118
|
-
RemotePlaybackState.IDLE -> PlayerState.NONE
|
|
119
|
-
RemotePlaybackState.ENDED -> PlayerState.ENDED
|
|
120
|
-
RemotePlaybackState.ERROR -> PlayerState.ERROR
|
|
121
|
-
}
|
|
122
113
|
}
|
|
@@ -124,6 +124,19 @@ sealed class CastSession {
|
|
|
124
124
|
/** Receiver-driven playback state for `PlaybackStateRouter`. */
|
|
125
125
|
abstract val state: StateFlow<RemotePlaybackState>
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* `true` once the receiver has reported any state other than
|
|
129
|
+
* [RemotePlaybackState.IDLE]. Latches on the first such report and stays
|
|
130
|
+
* set for the session's lifetime.
|
|
131
|
+
*
|
|
132
|
+
* State reads use this to decide whether the receiver can answer yet: a
|
|
133
|
+
* session that has just activated still holds its initial IDLE, and the
|
|
134
|
+
* local engine is the better answer until the receiver speaks. Once it
|
|
135
|
+
* has, the receiver is authoritative — including a later IDLE, which
|
|
136
|
+
* means it genuinely has nothing loaded.
|
|
137
|
+
*/
|
|
138
|
+
abstract val hasReportedState: Boolean
|
|
139
|
+
|
|
127
140
|
/** Receiver-driven position in ms. */
|
|
128
141
|
abstract val positionMs: StateFlow<Long>
|
|
129
142
|
|
|
@@ -30,6 +30,25 @@ internal object CastTransportRouter {
|
|
|
30
30
|
return true
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Flip the receiver's play state. The receiver's own state picks the
|
|
35
|
+
* direction — the local engine is parked and paused while casting, so its
|
|
36
|
+
* `playWhenReady` would resume the receiver on every press. Anything that
|
|
37
|
+
* represents "trying to play" toggles to pause, so a press during buffering
|
|
38
|
+
* stops rather than restarts.
|
|
39
|
+
*/
|
|
40
|
+
fun routeTogglePlayPause(): Boolean {
|
|
41
|
+
val remote = PlaybackStateRouter.activeRemote() ?: return false
|
|
42
|
+
val active = when (remote.state.value) {
|
|
43
|
+
RemotePlaybackState.PLAYING,
|
|
44
|
+
RemotePlaybackState.BUFFERING,
|
|
45
|
+
RemotePlaybackState.LOADING -> true
|
|
46
|
+
else -> false
|
|
47
|
+
}
|
|
48
|
+
if (active) remote.pause() else remote.play()
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
fun routeStop(): Boolean {
|
|
34
53
|
val remote = PlaybackStateRouter.activeRemote() ?: return false
|
|
35
54
|
remote.stop()
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package com.margelo.nitro.queueplayer.cast
|
|
2
|
+
|
|
3
|
+
import com.margelo.nitro.queueplayer.PlayerState
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map the protocol-neutral receiver state to the JS-facing [PlayerState].
|
|
7
|
+
* Shared by the cast event fan-out and `TrackPlayer.getState`, so a state read
|
|
8
|
+
* and the event stream can't disagree about the same receiver.
|
|
9
|
+
*/
|
|
10
|
+
internal fun mapRemotePlaybackState(state: RemotePlaybackState): PlayerState = when (state) {
|
|
11
|
+
RemotePlaybackState.PLAYING -> PlayerState.PLAYING
|
|
12
|
+
RemotePlaybackState.PAUSED -> PlayerState.PAUSED
|
|
13
|
+
RemotePlaybackState.LOADING -> PlayerState.BUFFERING
|
|
14
|
+
RemotePlaybackState.BUFFERING -> PlayerState.BUFFERING
|
|
15
|
+
RemotePlaybackState.IDLE -> PlayerState.NONE
|
|
16
|
+
RemotePlaybackState.ENDED -> PlayerState.ENDED
|
|
17
|
+
RemotePlaybackState.ERROR -> PlayerState.ERROR
|
|
18
|
+
}
|
|
@@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope
|
|
|
9
9
|
import kotlinx.coroutines.Dispatchers
|
|
10
10
|
import kotlinx.coroutines.SupervisorJob
|
|
11
11
|
import kotlinx.coroutines.launch
|
|
12
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* A live AirPlay 2 streaming session backed by libairplay2 via [AirPlay2JNI].
|
|
@@ -28,7 +29,7 @@ class AirPlay2Session(
|
|
|
28
29
|
private val handle: Long,
|
|
29
30
|
private val host: String,
|
|
30
31
|
private val port: Int,
|
|
31
|
-
) : CastSession.PcmStream(), MetadataAwareSession {
|
|
32
|
+
) : CastSession.PcmStream(), MetadataAwareSession, MetadataSyncTarget {
|
|
32
33
|
|
|
33
34
|
companion object {
|
|
34
35
|
private const val TAG = "AirPlay2Session"
|
|
@@ -36,7 +37,10 @@ class AirPlay2Session(
|
|
|
36
37
|
|
|
37
38
|
private val sink = AirPlay2Sink(handle)
|
|
38
39
|
@Volatile private var closed = false
|
|
39
|
-
|
|
40
|
+
// Swapped on the player's looper by onActivated, read and cleared on the
|
|
41
|
+
// disconnecting thread — the exchange has to be atomic or a teardown can
|
|
42
|
+
// read a stale null and leave the previous sync running.
|
|
43
|
+
private val metadataSync = AtomicReference<AirPlayMetadataSync?>()
|
|
40
44
|
|
|
41
45
|
/** Internal scope for fire-and-forget native teardown. */
|
|
42
46
|
private val teardownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
@@ -53,16 +57,21 @@ class AirPlay2Session(
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
override fun onActivated(player: Player, scope: CoroutineScope) {
|
|
56
|
-
|
|
60
|
+
if (closed) return
|
|
61
|
+
// Stop any prior sync before starting the replacement, so a repeat
|
|
57
62
|
// activation (the player swaps under a still-connected session) never
|
|
58
63
|
// leaves an orphaned sync running against the previous player.
|
|
59
|
-
|
|
60
|
-
metadataSync
|
|
64
|
+
val next = AirPlayMetadataSync(context, this, player, scope)
|
|
65
|
+
metadataSync.getAndSet(next)?.stop()
|
|
66
|
+
next.start()
|
|
67
|
+
// A close() that ran while this was publishing exchanged out a null
|
|
68
|
+
// and tore down without seeing `next`, so re-check: otherwise a live
|
|
69
|
+
// sync stays bound to a closed session for the process lifetime.
|
|
70
|
+
if (closed) metadataSync.getAndSet(null)?.stop()
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
override fun onDeactivated() {
|
|
64
|
-
metadataSync?.stop()
|
|
65
|
-
metadataSync = null
|
|
74
|
+
metadataSync.getAndSet(null)?.stop()
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/**
|
|
@@ -103,17 +112,17 @@ class AirPlay2Session(
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
fun setMetadata(title: String?, artist: String?, album: String?) {
|
|
115
|
+
override fun setMetadata(title: String?, artist: String?, album: String?) {
|
|
107
116
|
if (closed) return
|
|
108
117
|
AirPlay2JNI.nativeAp2SetMetadata(handle, title, artist, album)
|
|
109
118
|
}
|
|
110
119
|
|
|
111
|
-
fun setArtwork(imageData: ByteArray, contentType: String
|
|
120
|
+
override fun setArtwork(imageData: ByteArray, contentType: String) {
|
|
112
121
|
if (closed) return
|
|
113
122
|
AirPlay2JNI.nativeAp2SetArtwork(handle, imageData, imageData.size)
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
fun setProgress(elapsedMs: Int, durationMs: Int) {
|
|
125
|
+
override fun setProgress(elapsedMs: Int, durationMs: Int) {
|
|
117
126
|
if (closed) return
|
|
118
127
|
AirPlay2JNI.nativeAp2SetProgressMs(handle, elapsedMs.toLong(), durationMs.toLong())
|
|
119
128
|
}
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt
CHANGED
|
@@ -14,19 +14,23 @@ import java.io.ByteArrayOutputStream
|
|
|
14
14
|
import java.net.URL
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Syncs ExoPlayer track metadata and playback progress to an
|
|
17
|
+
* Syncs ExoPlayer track metadata and playback progress to an AirPlay
|
|
18
|
+
* receiver through [MetadataSyncTarget].
|
|
18
19
|
*
|
|
19
|
-
* - On activation and on every track transition: sends
|
|
20
|
+
* - On activation and on every track transition: sends metadata
|
|
20
21
|
* (title/artist/album) and kicks off an async artwork fetch.
|
|
21
22
|
* - Every 5 seconds while playing: sends progress (elapsed/duration).
|
|
22
23
|
* - On seek: sends progress immediately.
|
|
23
24
|
* - On stop: cancels all coroutines and removes the player listener.
|
|
25
|
+
*
|
|
26
|
+
* One instance covers one activation — [start] then [stop]. Starting again
|
|
27
|
+
* after a stop is a no-op; a new activation builds a new instance.
|
|
24
28
|
*/
|
|
25
29
|
internal class AirPlayMetadataSync(
|
|
26
30
|
private val context: Context,
|
|
27
|
-
private val
|
|
31
|
+
private val target: MetadataSyncTarget,
|
|
28
32
|
private val player: Player,
|
|
29
|
-
|
|
33
|
+
scope: CoroutineScope,
|
|
30
34
|
) {
|
|
31
35
|
companion object {
|
|
32
36
|
private const val TAG = "AirPlayMetadataSync"
|
|
@@ -35,11 +39,32 @@ internal class AirPlayMetadataSync(
|
|
|
35
39
|
private const val JPEG_QUALITY = 80
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Owns every coroutine this sync launches, so [stop] can cancel them as
|
|
44
|
+
* one atomic operation from any thread. Cancelling the scope needs no
|
|
45
|
+
* field read, so a teardown racing the player looper cannot miss a job,
|
|
46
|
+
* and a launch that loses the race starts already-cancelled rather than
|
|
47
|
+
* outliving the session it pushes into.
|
|
48
|
+
*/
|
|
49
|
+
private val syncScope =
|
|
50
|
+
CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job]))
|
|
51
|
+
|
|
52
|
+
// Touched only on the player's application looper.
|
|
38
53
|
private var progressJob: Job? = null
|
|
39
54
|
private var artworkJob: Job? = null
|
|
40
55
|
private var lastArtworkUri: String? = null
|
|
56
|
+
|
|
57
|
+
// Written by start/stop on whichever thread activated or tore the
|
|
58
|
+
// session down; read on the looper and by the progress loop.
|
|
59
|
+
@Volatile
|
|
41
60
|
private var started = false
|
|
42
61
|
|
|
62
|
+
// Latches on stop so a restart cannot half-revive a spent instance: the
|
|
63
|
+
// scope is already cancelled, so the listener would still push metadata
|
|
64
|
+
// while the progress loop and artwork fetch stay dead.
|
|
65
|
+
@Volatile
|
|
66
|
+
private var stopped = false
|
|
67
|
+
|
|
43
68
|
// ExoPlayer rejects add/removeListener + state reads off its application
|
|
44
69
|
// looper. start()/stop() are driven by session activation/close, and
|
|
45
70
|
// close() runs on whichever thread disconnected (the JS thread for a
|
|
@@ -48,6 +73,10 @@ internal class AirPlayMetadataSync(
|
|
|
48
73
|
// already on it.
|
|
49
74
|
private val playerHandler = Handler(player.applicationLooper)
|
|
50
75
|
|
|
76
|
+
// Both protocols log under one tag, so every line says which sender it
|
|
77
|
+
// came from.
|
|
78
|
+
private val targetName = target.javaClass.simpleName
|
|
79
|
+
|
|
51
80
|
private fun onPlayerLooper(block: () -> Unit) {
|
|
52
81
|
if (Looper.myLooper() == player.applicationLooper) block()
|
|
53
82
|
else playerHandler.post(block)
|
|
@@ -69,6 +98,7 @@ internal class AirPlayMetadataSync(
|
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
|
101
|
+
if (!started) return
|
|
72
102
|
if (playWhenReady) {
|
|
73
103
|
startProgressTimer()
|
|
74
104
|
} else {
|
|
@@ -79,37 +109,43 @@ internal class AirPlayMetadataSync(
|
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
fun start() {
|
|
82
|
-
if (started) return
|
|
112
|
+
if (started || stopped) return
|
|
83
113
|
started = true
|
|
84
114
|
onPlayerLooper {
|
|
115
|
+
// A stop() between this post and its execution has already run its
|
|
116
|
+
// own removeListener, so attaching now would leave the listener on
|
|
117
|
+
// the player for its lifetime.
|
|
118
|
+
if (stopped) return@onPlayerLooper
|
|
85
119
|
player.addListener(listener)
|
|
86
120
|
sendCurrentMetadata()
|
|
87
|
-
if (player.playWhenReady) {
|
|
121
|
+
if (started && player.playWhenReady) {
|
|
88
122
|
startProgressTimer()
|
|
89
123
|
}
|
|
90
124
|
}
|
|
91
|
-
Log.i(TAG, "Started metadata sync")
|
|
125
|
+
Log.i(TAG, "Started metadata sync for $targetName")
|
|
92
126
|
}
|
|
93
127
|
|
|
94
128
|
fun stop() {
|
|
95
|
-
if (
|
|
129
|
+
if (stopped) return
|
|
130
|
+
stopped = true
|
|
96
131
|
started = false
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
syncScope.cancel()
|
|
133
|
+
// Listener removal has to hop to the looper, so a callback queued
|
|
134
|
+
// ahead of it can still run; the `started` gate in the senders is
|
|
135
|
+
// what stops those from reaching a session that is being closed.
|
|
101
136
|
onPlayerLooper { player.removeListener(listener) }
|
|
102
|
-
Log.i(TAG, "Stopped metadata sync")
|
|
137
|
+
Log.i(TAG, "Stopped metadata sync for $targetName")
|
|
103
138
|
}
|
|
104
139
|
|
|
105
140
|
private fun sendCurrentMetadata() {
|
|
141
|
+
if (!started) return
|
|
106
142
|
val meta = player.currentMediaItem?.mediaMetadata ?: return
|
|
107
143
|
val title = meta.title?.toString()
|
|
108
144
|
val artist = meta.artist?.toString()
|
|
109
145
|
val album = meta.albumTitle?.toString()
|
|
110
146
|
|
|
111
|
-
Log.i(TAG, "
|
|
112
|
-
|
|
147
|
+
Log.i(TAG, "$targetName sending metadata: title=$title, artist=$artist, album=$album")
|
|
148
|
+
target.setMetadata(title, artist, album)
|
|
113
149
|
|
|
114
150
|
// Send initial progress for the new track
|
|
115
151
|
sendProgress()
|
|
@@ -123,16 +159,17 @@ internal class AirPlayMetadataSync(
|
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
private fun sendProgress() {
|
|
162
|
+
if (!started) return
|
|
126
163
|
val position = player.currentPosition.toInt()
|
|
127
164
|
val duration = player.duration.let { if (it > 0) it.toInt() else 0 }
|
|
128
165
|
if (duration > 0) {
|
|
129
|
-
|
|
166
|
+
target.setProgress(position, duration)
|
|
130
167
|
}
|
|
131
168
|
}
|
|
132
169
|
|
|
133
170
|
private fun startProgressTimer() {
|
|
134
171
|
progressJob?.cancel()
|
|
135
|
-
progressJob =
|
|
172
|
+
progressJob = syncScope.launch {
|
|
136
173
|
while (isActive) {
|
|
137
174
|
delay(PROGRESS_INTERVAL_MS)
|
|
138
175
|
if (player.isPlaying) {
|
|
@@ -144,7 +181,7 @@ internal class AirPlayMetadataSync(
|
|
|
144
181
|
|
|
145
182
|
private fun fetchAndSendArtwork(uriString: String) {
|
|
146
183
|
artworkJob?.cancel()
|
|
147
|
-
artworkJob =
|
|
184
|
+
artworkJob = syncScope.launch(Dispatchers.IO) {
|
|
148
185
|
try {
|
|
149
186
|
val uri = Uri.parse(uriString)
|
|
150
187
|
val scheme = uri.scheme ?: return@launch
|
|
@@ -185,9 +222,9 @@ internal class AirPlayMetadataSync(
|
|
|
185
222
|
bitmap.recycle()
|
|
186
223
|
|
|
187
224
|
val bytes = baos.toByteArray()
|
|
188
|
-
if (isActive) {
|
|
189
|
-
|
|
190
|
-
Log.i(TAG, "
|
|
225
|
+
if (isActive && started) {
|
|
226
|
+
target.setArtwork(bytes, "image/jpeg")
|
|
227
|
+
Log.i(TAG, "$targetName sent artwork: ${bytes.size} bytes from $uriString")
|
|
191
228
|
}
|
|
192
229
|
} catch (e: Exception) {
|
|
193
230
|
if (e !is CancellationException) {
|