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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
metadataSync
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt
CHANGED
|
@@ -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 = {
|
|
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)
|
|
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"
|
|
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
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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(
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
+
}
|
package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt
ADDED
|
@@ -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
|
+
}
|