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
@@ -6,6 +6,7 @@ import androidx.media3.exoplayer.audio.AudioSink
6
6
  import com.margelo.nitro.queueplayer.cast.CastSession
7
7
  import com.margelo.nitro.queueplayer.cast.MetadataAwareSession
8
8
  import kotlinx.coroutines.CoroutineScope
9
+ import java.util.concurrent.atomic.AtomicReference
9
10
 
10
11
  /**
11
12
  * A live AirPlay 1 streaming session backed by libraop via [AirplayJNI].
@@ -25,15 +26,22 @@ class AirPlaySession(
25
26
  private val handle: Long,
26
27
  private val host: String,
27
28
  private val port: Int,
28
- ) : CastSession.PcmStream(), MetadataAwareSession {
29
+ ) : CastSession.PcmStream(), MetadataAwareSession, MetadataSyncTarget {
29
30
 
30
31
  companion object {
31
32
  private const val TAG = "AirPlaySession"
32
33
  }
33
34
 
34
35
  private val sink = AirPlaySink(handle)
36
+
37
+ // Read by every sender, set by close() on whichever thread disconnected.
38
+ @Volatile
35
39
  private var closed = false
36
- private var metadataSync: AirPlayMetadataSync? = null
40
+
41
+ // Swapped on the player's looper by onActivated, read and cleared on the
42
+ // disconnecting thread — the exchange has to be atomic or a teardown can
43
+ // read a stale null and leave the previous sync running.
44
+ private val metadataSync = AtomicReference<AirPlayMetadataSync?>()
37
45
 
38
46
  override fun audioSink(): AudioSink = sink
39
47
 
@@ -46,22 +54,29 @@ class AirPlaySession(
46
54
  }
47
55
 
48
56
  override fun onActivated(player: Player, scope: CoroutineScope) {
49
- // Re-entrant safe: stop any prior sync before rebinding so a repeat
57
+ if (closed) return
58
+ // Stop any prior sync before starting the replacement, so a repeat
50
59
  // activation (the player swaps under a still-connected session) never
51
60
  // leaves an orphaned sync running against the previous player.
52
- metadataSync?.stop()
53
- metadataSync = AirPlayMetadataSync(context, this, player, scope).also { it.start() }
61
+ val next = AirPlayMetadataSync(context, this, player, scope)
62
+ metadataSync.getAndSet(next)?.stop()
63
+ next.start()
64
+ // A close() that ran while this was publishing exchanged out a null
65
+ // and tore down without seeing `next`, so re-check: otherwise a live
66
+ // sync stays bound to a closed session for the process lifetime.
67
+ if (closed) metadataSync.getAndSet(null)?.stop()
54
68
  }
55
69
 
56
70
  override fun onDeactivated() {
57
- metadataSync?.stop()
58
- metadataSync = null
71
+ metadataSync.getAndSet(null)?.stop()
59
72
  }
60
73
 
61
74
  override fun close() {
62
75
  if (closed) return
63
- onDeactivated()
76
+ // Closed before the sync is torn down so an in-flight sender bails at
77
+ // its own guard rather than reaching a handle nativeDestroy has freed.
64
78
  closed = true
79
+ onDeactivated()
65
80
  Log.i(TAG, "Closing AirPlay session to $host:$port")
66
81
  // Release the sink BEFORE the native handle is torn down: this sets
67
82
  // the sink's volatile `released` flag so the renderer thread stops
@@ -73,20 +88,17 @@ class AirPlaySession(
73
88
  AirplayJNI.nativeDestroy(handle)
74
89
  }
75
90
 
76
- /** Send DAAP metadata (title/artist/album) to the receiver. */
77
- fun setMetadata(title: String?, artist: String?, album: String?) {
91
+ override fun setMetadata(title: String?, artist: String?, album: String?) {
78
92
  if (closed) return
79
93
  AirplayJNI.nativeSetDaap(handle, title, artist, album)
80
94
  }
81
95
 
82
- /** Send artwork image to the receiver. */
83
- fun setArtwork(imageData: ByteArray, contentType: String = "image/jpeg") {
96
+ override fun setArtwork(imageData: ByteArray, contentType: String) {
84
97
  if (closed) return
85
98
  AirplayJNI.nativeSetArtwork(handle, imageData, imageData.size, contentType)
86
99
  }
87
100
 
88
- /** Update the progress bar on the receiver. */
89
- fun setProgress(elapsedMs: Int, durationMs: Int) {
101
+ override fun setProgress(elapsedMs: Int, durationMs: Int) {
90
102
  if (closed) return
91
103
  AirplayJNI.nativeSetProgressMs(handle, elapsedMs, durationMs)
92
104
  }
@@ -0,0 +1,20 @@
1
+ package com.margelo.nitro.queueplayer.cast.airplay
2
+
3
+ /**
4
+ * What an AirPlay session accepts for the receiver's own display while it
5
+ * streams. [AirPlayMetadataSync] pushes into this; AirPlay 1 and AirPlay 2
6
+ * sessions implement it over their respective native senders.
7
+ */
8
+ interface MetadataSyncTarget {
9
+ /** Send track metadata (title/artist/album) to the receiver. */
10
+ fun setMetadata(title: String?, artist: String?, album: String?)
11
+
12
+ /** Update the progress bar on the receiver. */
13
+ fun setProgress(elapsedMs: Int, durationMs: Int)
14
+
15
+ /**
16
+ * Send artwork image bytes to the receiver. A receiver whose sender
17
+ * protocol carries no content type ignores [contentType].
18
+ */
19
+ fun setArtwork(imageData: ByteArray, contentType: String)
20
+ }
@@ -14,6 +14,8 @@ import com.google.android.gms.cast.MediaQueueItem
14
14
  import com.google.android.gms.cast.MediaSeekOptions
15
15
  import com.google.android.gms.cast.MediaStatus
16
16
  import com.google.android.gms.cast.framework.media.RemoteMediaClient
17
+ import com.google.android.gms.common.api.PendingResult
18
+ import com.margelo.nitro.queueplayer.BuildConfig
17
19
  import com.margelo.nitro.queueplayer.cast.CastBackends
18
20
  import com.margelo.nitro.queueplayer.cast.CastMediaItem
19
21
  import com.margelo.nitro.queueplayer.cast.CastSession
@@ -88,6 +90,10 @@ internal class ChromecastSession(
88
90
  private val _seekCompleted = MutableSharedFlow<Long>(extraBufferCapacity = 4)
89
91
 
90
92
  override val state: StateFlow<RemotePlaybackState> = _state.asStateFlow()
93
+
94
+ @Volatile
95
+ override var hasReportedState: Boolean = false
96
+ private set
91
97
  override val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
92
98
  override val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
93
99
  override val currentTrackIndex: StateFlow<Int> = _currentTrackIndex.asStateFlow()
@@ -110,7 +116,13 @@ internal class ChromecastSession(
110
116
  private var serverHandle: MediaServerHandle? = null
111
117
 
112
118
  private val remoteMediaCallbacks = RemoteMediaCallbacks(
113
- onStateChanged = { _state.value = it },
119
+ onStateChanged = { next ->
120
+ if (BuildConfig.DEBUG && _state.value != next) {
121
+ Log.d(TAG, "receiver state ${_state.value} -> $next")
122
+ }
123
+ if (next != RemotePlaybackState.IDLE) hasReportedState = true
124
+ _state.value = next
125
+ },
114
126
  onPositionChanged = { _positionMs.value = it },
115
127
  onDurationChanged = { _durationMs.value = it },
116
128
  onEndedReason = { _endedReason.tryEmit(it) },
@@ -264,12 +276,13 @@ internal class ChromecastSession(
264
276
  _currentTrackIndex.value = clampedStart
265
277
  }
266
278
 
279
+ // No bitmask flag gates `play` — the mask models pause, not resume.
267
280
  override fun play() {
268
281
  fireRemote("play") { it.play() }
269
282
  }
270
283
 
271
284
  override fun pause() {
272
- fireRemote("pause") { it.pause() }
285
+ fireRemote("pause", requires = MediaStatus.COMMAND_PAUSE) { it.pause() }
273
286
  }
274
287
 
275
288
  override fun stop() {
@@ -277,11 +290,11 @@ internal class ChromecastSession(
277
290
  }
278
291
 
279
292
  override fun queueNext() {
280
- fireRemote("queueNext") { it.queueNext(null) }
293
+ fireRemote("queueNext", requires = MediaStatus.COMMAND_QUEUE_NEXT) { it.queueNext(null) }
281
294
  }
282
295
 
283
296
  override fun queuePrev() {
284
- fireRemote("queuePrev") { it.queuePrev(null) }
297
+ fireRemote("queuePrev", requires = MediaStatus.COMMAND_QUEUE_PREVIOUS) { it.queuePrev(null) }
285
298
  }
286
299
 
287
300
  override fun skipToQueueIndex(index: Int) {
@@ -292,8 +305,8 @@ internal class ChromecastSession(
292
305
  // synced that slot yet; skip rather than jump blind.
293
306
  fireRemote("skipToQueueIndex") { client ->
294
307
  val itemId = receiverItemIdAtIndex(index)
295
- if (itemId == MediaQueueItem.INVALID_ITEM_ID) return@fireRemote
296
- client.queueJumpToItem(itemId, null)
308
+ if (itemId == MediaQueueItem.INVALID_ITEM_ID) null
309
+ else client.queueJumpToItem(itemId, null)
297
310
  }
298
311
  }
299
312
 
@@ -367,7 +380,9 @@ internal class ChromecastSession(
367
380
  }
368
381
 
369
382
  override fun setPlaybackRate(rate: Float) {
370
- fireRemote("setPlaybackRate") { it.setPlaybackRate(rate.toDouble()) }
383
+ fireRemote("setPlaybackRate", requires = MediaStatus.COMMAND_PLAYBACK_RATE) {
384
+ it.setPlaybackRate(rate.toDouble())
385
+ }
371
386
  }
372
387
 
373
388
  override fun setRepeatMode(mode: RemoteRepeatMode) {
@@ -435,11 +450,26 @@ internal class ChromecastSession(
435
450
  }
436
451
 
437
452
  /**
438
- * Post [block] to main and invoke it with the current remote media
439
- * client. Silently skips when the session is closed or the client
440
- * is unavailable (log only — transport calls are fire-and-forget).
453
+ * Post [block] to main and invoke it with the current [RemoteMediaClient].
454
+ * Silently skips when the session is closed or the client is unavailable
455
+ * (log only — transport calls are fire-and-forget).
456
+ *
457
+ * [block] returns the [PendingResult] the SDK scheduled, or `null` when it
458
+ * declined to issue one. A returned result gets a callback so a
459
+ * receiver-side rejection is reported instead of vanishing — without it a
460
+ * refused command is indistinguishable from one the receiver honoured.
461
+ *
462
+ * [requires] names the [MediaStatus] command bitmask flag the receiver must
463
+ * advertise for this command (`null` for commands not gated by the mask).
464
+ * The receiver publishes support per media item, so an item can accept queue
465
+ * commands while refusing `pause` — worth a warning, since the command is
466
+ * otherwise dropped in silence.
441
467
  */
442
- private fun fireRemote(label: String, block: (RemoteMediaClient) -> Unit) {
468
+ private fun fireRemote(
469
+ label: String,
470
+ requires: Long? = null,
471
+ block: (RemoteMediaClient) -> PendingResult<RemoteMediaClient.MediaChannelResult>?,
472
+ ) {
443
473
  if (closed) return
444
474
  runOnMain {
445
475
  if (closed) return@runOnMain
@@ -448,8 +478,38 @@ internal class ChromecastSession(
448
478
  Log.w(TAG, "$label: no RemoteMediaClient — session not fully live?")
449
479
  return@runOnMain
450
480
  }
451
- runCatching { block(client) }
452
- .onFailure { Log.w(TAG, "$label failed", it) }
481
+ val status = client.mediaStatus
482
+ if (requires != null && status != null && !status.isMediaCommandSupported(requires)) {
483
+ Log.w(
484
+ TAG,
485
+ "$label: receiver does not advertise support for this command " +
486
+ "(supportedMediaCommands=${status.supportedMediaCommands}); sending anyway",
487
+ )
488
+ }
489
+ if (BuildConfig.DEBUG) Log.d(TAG, "$label: issuing")
490
+ val outcome = runCatching { block(client) }
491
+ val thrown = outcome.exceptionOrNull()
492
+ if (thrown != null) {
493
+ Log.w(TAG, "$label failed", thrown)
494
+ return@runOnMain
495
+ }
496
+ // A null result means this call site declined to issue anything — the
497
+ // only such case is a queue index the receiver's model hasn't synced
498
+ // yet, which is a normal transient, not a fault.
499
+ val pending = outcome.getOrNull()
500
+ if (pending == null) {
501
+ if (BuildConfig.DEBUG) Log.d(TAG, "$label: no request issued")
502
+ return@runOnMain
503
+ }
504
+ pending.setResultCallback { result ->
505
+ if (closed) return@setResultCallback
506
+ if (!result.status.isSuccess) {
507
+ Log.w(
508
+ TAG,
509
+ "$label failed: ${result.status.statusCode} ${result.status.statusMessage}",
510
+ )
511
+ }
512
+ }
453
513
  }
454
514
  }
455
515
 
@@ -4,7 +4,6 @@ import org.junit.Assert.assertArrayEquals
4
4
  import org.junit.Assert.assertEquals
5
5
  import org.junit.Assert.assertFalse
6
6
  import org.junit.Assert.assertTrue
7
- import org.junit.Assume.assumeTrue
8
7
  import org.junit.Test
9
8
  import org.junit.runner.RunWith
10
9
  import org.robolectric.RobolectricTestRunner
@@ -168,7 +167,7 @@ class EqualizerEngineTest {
168
167
  fun `reattach on same session is idempotent`() {
169
168
  val engine = EqualizerEngine()
170
169
  val first = engine.attach(audioSessionId = 123, channelCount = 2)
171
- assumeTrue("Robolectric audio-effect shadow refused first attach", first)
170
+ assertTrue("first attach succeeds", first)
172
171
  val second = engine.attach(audioSessionId = 123, channelCount = 6)
173
172
  assertTrue("repeat attach on the same session succeeds", second)
174
173
  assertTrue(
@@ -181,7 +180,7 @@ class EqualizerEngineTest {
181
180
  fun `attachAll holds multiple sessions simultaneously for crossfade`() {
182
181
  val engine = EqualizerEngine()
183
182
  val results = engine.attachAll(intArrayOf(101, 102))
184
- assumeTrue("Robolectric audio-effect shadow refused attach", results[101] == true)
183
+ assertTrue("session attaches", results[101] == true)
185
184
  assertTrue("incoming session also attached", results[102] == true)
186
185
  assertTrue("at least one session held", engine.isAttached())
187
186
  }
@@ -190,7 +189,7 @@ class EqualizerEngineTest {
190
189
  fun `attachAll diff releases sessions no longer in the input array`() {
191
190
  val engine = EqualizerEngine()
192
191
  val first = engine.attachAll(intArrayOf(201, 202))
193
- assumeTrue("Robolectric audio-effect shadow refused attach", first[201] == true)
192
+ assertTrue("session attaches", first[201] == true)
194
193
  val second = engine.attachAll(intArrayOf(202))
195
194
  // 201 dropped, 202 retained.
196
195
  assertTrue("retained session marked succeeded", second[202] == true)
@@ -201,22 +200,17 @@ class EqualizerEngineTest {
201
200
  fun `detachAll releases every per-session instance`() {
202
201
  val engine = EqualizerEngine()
203
202
  val results = engine.attachAll(intArrayOf(301, 302))
204
- assumeTrue("Robolectric audio-effect shadow refused attach", results[301] == true)
203
+ assertTrue("session attaches", results[301] == true)
205
204
  engine.detachAll()
206
205
  assertTrue("no sessions held after detachAll", !engine.isAttached())
207
206
  }
208
207
 
209
208
  @Test
210
- fun `setBandGain fans gain across all attached sessions`() {
209
+ fun `setBandGain buffers the gain while sessions are attached`() {
211
210
  val engine = EqualizerEngine()
212
211
  val results = engine.attachAll(intArrayOf(401, 402))
213
- assumeTrue("Robolectric audio-effect shadow refused attach", results[401] == true)
212
+ assertTrue("session attaches", results[401] == true)
214
213
  engine.setBandGain(3, 4f)
215
214
  assertEquals(4f, engine.getBandGains()[3])
216
- // The buffered gain mirrors what each per-session instance
217
- // received via setPreEqBandAllChannelsTo. Direct hardware
218
- // verification requires a real device — Robolectric's audio-
219
- // effect shadow doesn't expose the per-band gain readback —
220
- // so this asserts the lib-side state mirrors the call.
221
215
  }
222
216
  }
@@ -4,20 +4,22 @@ import org.junit.Assert.assertArrayEquals
4
4
  import org.junit.Assert.assertEquals
5
5
  import org.junit.Assert.assertFalse
6
6
  import org.junit.Assert.assertTrue
7
+ import android.media.audiofx.Equalizer
7
8
  import org.junit.Test
8
9
  import org.junit.runner.RunWith
9
10
  import org.robolectric.RobolectricTestRunner
11
+ import org.robolectric.shadow.api.Shadow
10
12
  import org.robolectric.annotation.Config
13
+ import org.robolectric.shadows.ShadowAudioEffect
11
14
 
12
15
  /**
13
- * State-mirror coverage for [EqualizerLegacyEngine] on the API <28 path.
14
- * Robolectric's audio-effect shadow doesn't model the hardware-side
15
- * band centre frequencies + gain range; the band-mapping + dB↔mB
16
- * conversion are the invariants this covers. Legacy AudioEffect
17
- * behaviour varies per OEM.
16
+ * State-mirror coverage for [EqualizerLegacyEngine] on the API <28 path:
17
+ * the band mapping, the dB↔mB conversion, and what reaches each attached
18
+ * session. [ShadowEqualizer] supplies the hardware-side band count, centre
19
+ * frequencies and gain range that legacy AudioEffect varies per OEM.
18
20
  */
19
21
  @RunWith(RobolectricTestRunner::class)
20
- @Config(sdk = [27])
22
+ @Config(sdk = [27], shadows = [ShadowEqualizer::class])
21
23
  class EqualizerLegacyEngineTest {
22
24
 
23
25
  @Test
@@ -181,9 +183,7 @@ class EqualizerLegacyEngineTest {
181
183
  fun `attachAll on empty array detaches every active instance`() {
182
184
  val engine = EqualizerLegacyEngine()
183
185
  val first = engine.attachAll(intArrayOf(501, 502))
184
- org.junit.Assume.assumeTrue(
185
- "Robolectric audio-effect shadow refused first attach", first[501] == true,
186
- )
186
+ assertEquals("both sessions attach", mapOf(501 to true, 502 to true), first)
187
187
  val second = engine.attachAll(intArrayOf())
188
188
  assertEquals("empty array clears the map", 0, second.size)
189
189
  assertFalse("no sessions held after empty attachAll", engine.isAttached())
@@ -193,9 +193,7 @@ class EqualizerLegacyEngineTest {
193
193
  fun `detachAll releases every per-session instance`() {
194
194
  val engine = EqualizerLegacyEngine()
195
195
  val results = engine.attachAll(intArrayOf(601, 602))
196
- org.junit.Assume.assumeTrue(
197
- "Robolectric audio-effect shadow refused attach", results[601] == true,
198
- )
196
+ assertEquals("both sessions attach", mapOf(601 to true, 602 to true), results)
199
197
  engine.detachAll()
200
198
  assertFalse("no sessions held after detachAll", engine.isAttached())
201
199
  }
@@ -204,10 +202,23 @@ class EqualizerLegacyEngineTest {
204
202
  fun `setBandGain fans gain across every attached session`() {
205
203
  val engine = EqualizerLegacyEngine()
206
204
  val results = engine.attachAll(intArrayOf(701, 702))
207
- org.junit.Assume.assumeTrue(
208
- "Robolectric audio-effect shadow refused attach", results[701] == true,
209
- )
205
+ assertEquals("both sessions attach", mapOf(701 to true, 702 to true), results)
206
+
210
207
  engine.setBandGain(4, 5f)
208
+
211
209
  assertEquals("buffered band gain mirrors the call", 5f, engine.getBandGains()[4])
210
+ // Our 500 Hz band maps onto the shadow's 230 Hz legacy band, and +5 dB
211
+ // is 500 mB against a ±1500 mB device range.
212
+ val legacyBand: Short = 1
213
+ assertEquals("session 701 receives the gain", 500, levelFor(701, legacyBand))
214
+ assertEquals("session 702 receives the gain", 500, levelFor(702, legacyBand))
215
+ }
216
+
217
+ /** Band level the session's own equalizer reports, in millibels. */
218
+ private fun levelFor(sessionId: Int, band: Short): Int {
219
+ val effect = ShadowAudioEffect.getAudioEffects().firstOrNull {
220
+ Shadow.extract<ShadowEqualizer>(it).audioSession == sessionId
221
+ } ?: error("no equalizer attached for session $sessionId")
222
+ return (effect as Equalizer).getBandLevel(band).toInt()
212
223
  }
213
224
  }
@@ -46,8 +46,12 @@ import org.robolectric.android.controller.ServiceController
46
46
  */
47
47
  @UnstableApi
48
48
  internal class PlaybackServiceTestHandle(
49
- private val controller: ServiceController<PlaybackService>
49
+ private val controller: ServiceController<PlaybackService>,
50
+ private val binder: PlaybackService.LocalBinder,
50
51
  ) {
52
+ /** The bound service's live engine, for asserting local-path transport. */
53
+ val engine: PlaybackEngine get() = binder.engine
54
+
51
55
  fun destroy() {
52
56
  controller.destroy()
53
57
  }
@@ -79,5 +83,5 @@ internal fun bindPlaybackServiceForTest(
79
83
  deferred.await()
80
84
  }
81
85
 
82
- return PlaybackServiceTestHandle(controller)
86
+ return PlaybackServiceTestHandle(controller, binder as PlaybackService.LocalBinder)
83
87
  }
@@ -0,0 +1,68 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.media.audiofx.Equalizer
4
+ import org.robolectric.annotation.Implements
5
+ import org.robolectric.shadows.ShadowAudioEffect
6
+ import java.nio.ByteBuffer
7
+ import java.nio.ByteOrder
8
+ import java.util.Optional
9
+
10
+ /**
11
+ * Models a five-band hardware equalizer so the legacy attach path resolves
12
+ * the same way on every run.
13
+ *
14
+ * [ShadowAudioEffect] answers the AudioEffect surface but falls back to a
15
+ * four-byte zero for any parameter it does not know, which the constructor's
16
+ * `short` reads cannot hold — so constructing an [Equalizer] throws before
17
+ * any band is queried. Supplying the parameters it reads makes a real attach
18
+ * distinguishable from a failed one. Levels written with `setBandLevel` land
19
+ * in the base shadow's parameter map, so they read back through the ordinary
20
+ * [Equalizer] API.
21
+ */
22
+ @Implements(Equalizer::class)
23
+ class ShadowEqualizer : ShadowAudioEffect() {
24
+
25
+ override fun getDefaultParameter(parameter: ByteBuffer): Optional<ByteBuffer> {
26
+ val key = parameter.duplicate().order(ByteOrder.nativeOrder())
27
+ if (key.remaining() < Int.SIZE_BYTES) return Optional.empty()
28
+ return when (key.int) {
29
+ PARAM_NUM_BANDS -> Optional.of(shorts(BAND_COUNT))
30
+ PARAM_LEVEL_RANGE -> Optional.of(shorts(MIN_LEVEL_MB, MAX_LEVEL_MB))
31
+ PARAM_BAND_LEVEL -> Optional.of(shorts(0))
32
+ PARAM_GET_NUM_OF_PRESETS -> Optional.of(shorts(0))
33
+ PARAM_CENTER_FREQ -> {
34
+ val band = if (key.remaining() >= Int.SIZE_BYTES) key.int else 0
35
+ Optional.of(ints(CENTER_FREQS_MILLI_HZ[band.coerceIn(0, BAND_COUNT - 1)]))
36
+ }
37
+ else -> Optional.empty()
38
+ }
39
+ }
40
+
41
+ private fun shorts(vararg values: Int): ByteBuffer =
42
+ ByteBuffer.allocate(values.size * Short.SIZE_BYTES).order(ByteOrder.nativeOrder()).apply {
43
+ values.forEach { putShort(it.toShort()) }
44
+ flip()
45
+ }
46
+
47
+ private fun ints(vararg values: Int): ByteBuffer =
48
+ ByteBuffer.allocate(values.size * Int.SIZE_BYTES).order(ByteOrder.nativeOrder()).apply {
49
+ values.forEach { putInt(it) }
50
+ flip()
51
+ }
52
+
53
+ companion object {
54
+ const val BAND_COUNT = 5
55
+ const val MIN_LEVEL_MB = -1500
56
+ const val MAX_LEVEL_MB = 1500
57
+
58
+ /** Centre frequencies in milliHz, matching a typical OEM five-band set. */
59
+ private val CENTER_FREQS_MILLI_HZ =
60
+ intArrayOf(60_000, 230_000, 910_000, 3_600_000, 14_000_000)
61
+
62
+ private const val PARAM_NUM_BANDS = 0
63
+ private const val PARAM_LEVEL_RANGE = 1
64
+ private const val PARAM_BAND_LEVEL = 2
65
+ private const val PARAM_CENTER_FREQ = 3
66
+ private const val PARAM_GET_NUM_OF_PRESETS = 7
67
+ }
68
+ }
@@ -0,0 +1,162 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import androidx.media3.common.util.UnstableApi
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import com.margelo.nitro.queueplayer.cast.FakeRemotePlayer
6
+ import com.margelo.nitro.queueplayer.cast.PlaybackStateRouter
7
+ import com.margelo.nitro.queueplayer.cast.RemotePlaybackState
8
+ import org.junit.After
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+ import org.robolectric.annotation.Config
15
+
16
+ /**
17
+ * OS-surface transport commands — hardware and Bluetooth media keys, and the
18
+ * media-session skip callbacks — drive the receiver while a cast session owns
19
+ * playback. Routing them to the local engine would play the phone speaker over
20
+ * the receiver, or skip a queue nobody is listening to.
21
+ */
22
+ @RunWith(RobolectricTestRunner::class)
23
+ @Config(sdk = [34])
24
+ @UnstableApi
25
+ class TrackPlayerCastCommandRoutingTest {
26
+
27
+ private lateinit var player: TrackPlayer
28
+ private lateinit var serviceHandle: PlaybackServiceTestHandle
29
+ private val remote = FakeRemotePlayer()
30
+
31
+ private fun context() = ApplicationProvider.getApplicationContext<android.content.Context>()
32
+
33
+ private fun handler() = player.mediaSessionCommandHandler
34
+
35
+ @Before
36
+ fun setUp() {
37
+ PlaybackStateRouter.setActive(null)
38
+ player = TrackPlayer()
39
+ serviceHandle = bindPlaybackServiceForTest(player, context())
40
+ player.configureInternal(
41
+ PlayerConfig(
42
+ httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
43
+ networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
44
+ visualizationEnabled = null, clampSeekToBuffered = null,
45
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
46
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
47
+ placeholderArtworkUri = null, skipToPreviousBehavior = null
48
+ ),
49
+ context()
50
+ )
51
+ }
52
+
53
+ @After
54
+ fun tearDown() {
55
+ PlaybackStateRouter.setActive(null)
56
+ player.destroyInternal()
57
+ serviceHandle.destroy()
58
+ }
59
+
60
+ @Test
61
+ fun `media play key drives the receiver`() {
62
+ PlaybackStateRouter.setActive(remote)
63
+
64
+ handler().onMediaPlay()
65
+
66
+ assertEquals(listOf("play"), remote.transportCalls)
67
+ }
68
+
69
+ @Test
70
+ fun `media pause key drives the receiver`() {
71
+ PlaybackStateRouter.setActive(remote)
72
+
73
+ handler().onMediaPause()
74
+
75
+ assertEquals(listOf("pause"), remote.transportCalls)
76
+ }
77
+
78
+ @Test
79
+ fun `toggle key pauses a playing receiver`() {
80
+ remote.reportState(RemotePlaybackState.PLAYING)
81
+ PlaybackStateRouter.setActive(remote)
82
+
83
+ handler().onMediaTogglePlayPause()
84
+
85
+ assertEquals(listOf("pause"), remote.transportCalls)
86
+ }
87
+
88
+ @Test
89
+ fun `toggle key pauses a buffering receiver`() {
90
+ remote.reportState(RemotePlaybackState.BUFFERING)
91
+ PlaybackStateRouter.setActive(remote)
92
+
93
+ handler().onMediaTogglePlayPause()
94
+
95
+ assertEquals(listOf("pause"), remote.transportCalls)
96
+ }
97
+
98
+ @Test
99
+ fun `toggle key pauses a loading receiver`() {
100
+ remote.reportState(RemotePlaybackState.LOADING)
101
+ PlaybackStateRouter.setActive(remote)
102
+
103
+ handler().onMediaTogglePlayPause()
104
+
105
+ assertEquals(listOf("pause"), remote.transportCalls)
106
+ }
107
+
108
+ @Test
109
+ fun `toggle key resumes a paused receiver`() {
110
+ remote.reportState(RemotePlaybackState.PAUSED)
111
+ PlaybackStateRouter.setActive(remote)
112
+
113
+ handler().onMediaTogglePlayPause()
114
+
115
+ assertEquals(listOf("play"), remote.transportCalls)
116
+ }
117
+
118
+ @Test
119
+ fun `media skip keys drive the receiver queue`() {
120
+ PlaybackStateRouter.setActive(remote)
121
+
122
+ handler().onMediaSkipNext()
123
+ handler().onMediaSkipPrevious()
124
+
125
+ assertEquals(listOf("queueNext", "queuePrev"), remote.transportCalls)
126
+ }
127
+
128
+ @Test
129
+ fun `session skip commands drive the receiver queue and stay claimed`() {
130
+ PlaybackStateRouter.setActive(remote)
131
+
132
+ assertEquals(true, handler().onSkipToNextCommand())
133
+ assertEquals(true, handler().onSkipToPreviousCommand())
134
+ assertEquals(listOf("queueNext", "queuePrev"), remote.transportCalls)
135
+ }
136
+
137
+ @Test
138
+ fun `play key drives the local engine when no cast session is active`() {
139
+ handler().onMediaPlay()
140
+
141
+ assertEquals(true, serviceHandle.engine.mediaSessionPlayer.playWhenReady)
142
+ assertEquals(emptyList<String>(), remote.transportCalls)
143
+ }
144
+
145
+ @Test
146
+ fun `pause key drives the local engine when no cast session is active`() {
147
+ handler().onMediaPlay()
148
+ handler().onMediaPause()
149
+
150
+ assertEquals(false, serviceHandle.engine.mediaSessionPlayer.playWhenReady)
151
+ assertEquals(emptyList<String>(), remote.transportCalls)
152
+ }
153
+
154
+ @Test
155
+ fun `toggle key drives the local engine when no cast session is active`() {
156
+ handler().onMediaPlay()
157
+ handler().onMediaTogglePlayPause()
158
+
159
+ assertEquals(false, serviceHandle.engine.mediaSessionPlayer.playWhenReady)
160
+ assertEquals(emptyList<String>(), remote.transportCalls)
161
+ }
162
+ }