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.
Files changed (32) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +72 -3
  3. package/android/src/main/cpp/airplay2_jni.cpp +23 -3
  4. package/android/src/main/cpp/airplay_jni.cpp +12 -3
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +31 -7
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +13 -0
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +19 -10
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +6 -12
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +26 -15
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +6 -2
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +162 -0
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +122 -0
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  23. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  24. package/ios/Cast/Core/CastSession.swift +11 -0
  25. package/ios/GaplessEngine.swift +11 -5
  26. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  27. package/ios/Tests/GaplessEngineLifecycleTests.swift +26 -0
  28. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  29. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  30. package/ios/TrackPlayer.swift +32 -32
  31. package/package.json +1 -1
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
@@ -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 published AAR via `consumerProguardFiles` and applied
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 Android AirPlay 2 receiver) ships
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
- __android_log_print(ANDROID_LOG_WARN, TAG,
550
- "AirPlay2JNI class not found RegisterNatives deferred");
551
- return JNI_VERSION_1_6;
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
- __android_log_print(ANDROID_LOG_WARN, TAG,
580
- "AirplayJNI class not loaded yet RegisterNatives deferred");
581
- return JNI_VERSION_1_6;
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 ExoPlayer's current state mapped onto the
2879
- * public [PlayerState] enum. Safe to call from any thread
2880
- * hops to main via [syncMain] so Media3's thread-assertion does
2881
- * not fire.
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
- syncMain { getStateInternal() }
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(mapState(remoteState), StateChangeReason.SYSTEM)
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()
@@ -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
- private var metadataSync: AirPlay2MetadataSync? = null
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
- // Re-entrant safe: stop any prior sync before rebinding so a repeat
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
- metadataSync?.stop()
60
- metadataSync = AirPlay2MetadataSync(context, this, player, scope).also { it.start() }
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 = "image/jpeg") {
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
  }
@@ -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 [AirPlaySession].
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 DAAP metadata
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 session: AirPlaySession,
31
+ private val target: MetadataSyncTarget,
28
32
  private val player: Player,
29
- private val scope: CoroutineScope,
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 (!started) return
129
+ if (stopped) return
130
+ stopped = true
96
131
  started = false
97
- progressJob?.cancel()
98
- progressJob = null
99
- artworkJob?.cancel()
100
- artworkJob = null
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, "Sending metadata: title=$title, artist=$artist, album=$album")
112
- session.setMetadata(title, artist, album)
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
- session.setProgress(position, duration)
166
+ target.setProgress(position, duration)
130
167
  }
131
168
  }
132
169
 
133
170
  private fun startProgressTimer() {
134
171
  progressJob?.cancel()
135
- progressJob = scope.launch {
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 = scope.launch(Dispatchers.IO) {
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
- session.setArtwork(bytes, "image/jpeg")
190
- Log.i(TAG, "Sent artwork: ${bytes.size} bytes from $uriString")
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) {