react-native-queue-player 1.0.6 → 1.0.7
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/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +6 -42
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +7 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +33 -23
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +15 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/package.json +1 -1
|
@@ -243,6 +243,7 @@ internal class AirPlayEngine(
|
|
|
243
243
|
override val bufferedPositionMs: Long get() = exoPlayer.bufferedPosition
|
|
244
244
|
override val isPlaying: Boolean get() = exoPlayer.isPlaying
|
|
245
245
|
override val currentMediaItem: MediaItem? get() = exoPlayer.currentMediaItem
|
|
246
|
+
override val isCrossfadeActive: Boolean get() = false
|
|
246
247
|
|
|
247
248
|
override val allMediaItems: List<MediaItem>
|
|
248
249
|
get() {
|
|
@@ -753,6 +753,9 @@ internal class CrossfadeEngine(
|
|
|
753
753
|
override val currentMediaItem: MediaItem?
|
|
754
754
|
get() = activePlayer.currentMediaItem
|
|
755
755
|
|
|
756
|
+
override val isCrossfadeActive: Boolean
|
|
757
|
+
get() = fadeJob?.isActive == true
|
|
758
|
+
|
|
756
759
|
override val allMediaItems: List<MediaItem>
|
|
757
760
|
get() {
|
|
758
761
|
val count = leadingPlayer.mediaItemCount
|
|
@@ -373,6 +373,9 @@ internal class GaplessEngine(
|
|
|
373
373
|
override val currentMediaItem: MediaItem?
|
|
374
374
|
get() = exoPlayer.currentMediaItem
|
|
375
375
|
|
|
376
|
+
override val isCrossfadeActive: Boolean
|
|
377
|
+
get() = false
|
|
378
|
+
|
|
376
379
|
override val allMediaItems: List<MediaItem>
|
|
377
380
|
get() {
|
|
378
381
|
val count = exoPlayer.mediaItemCount
|
|
@@ -113,6 +113,19 @@ object MediaItemBuilder {
|
|
|
113
113
|
return builder.build()
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Duration (ms) to write onto the current item's metadata for the API 33+
|
|
118
|
+
* System UI seekbar, or `null` to leave it untouched. A positive
|
|
119
|
+
* consumer-supplied duration wins; otherwise the decoded player duration is
|
|
120
|
+
* used; a non-positive player duration (unknown / live / `C.TIME_UNSET`,
|
|
121
|
+
* which is negative) yields `null`.
|
|
122
|
+
*/
|
|
123
|
+
internal fun durationToBackfill(existingDurationMs: Long?, playerDurationMs: Long): Long? {
|
|
124
|
+
if (existingDurationMs != null && existingDurationMs > 0L) return null
|
|
125
|
+
if (playerDurationMs <= 0L) return null
|
|
126
|
+
return playerDurationMs
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
/**
|
|
117
130
|
* URI for the bundled placeholder artwork in `res/raw/`. Cached
|
|
118
131
|
* per-process; the integer resource ID is package-stable at runtime
|
|
@@ -110,6 +110,14 @@ interface PlaybackEngine {
|
|
|
110
110
|
val isPlaying: Boolean
|
|
111
111
|
val currentMediaItem: MediaItem?
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* True while a crossfade volume ramp is in flight. Always `false` for
|
|
115
|
+
* [GaplessEngine]; [CrossfadeEngine] reports its live fade job. Callers
|
|
116
|
+
* that mutate the leading item (e.g. a metadata backfill) skip while this
|
|
117
|
+
* is true so they don't touch the outgoing leg mid-fade.
|
|
118
|
+
*/
|
|
119
|
+
val isCrossfadeActive: Boolean
|
|
120
|
+
|
|
113
121
|
/**
|
|
114
122
|
* Snapshot of every queued [MediaItem]. Called rarely (engine swap,
|
|
115
123
|
* diagnostic dumps); impl may allocate. The canonical queue model
|
|
@@ -307,10 +307,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
307
307
|
override fun mediaButtonDispatch() = this@PlaybackService.mediaButtonDispatch
|
|
308
308
|
|
|
309
309
|
override fun playerCommandsFor(
|
|
310
|
-
capability: SkipCapability
|
|
311
|
-
hasMediaItems: Boolean
|
|
310
|
+
capability: SkipCapability
|
|
312
311
|
): Player.Commands =
|
|
313
|
-
this@PlaybackService.playerCommandsFor(capability
|
|
312
|
+
this@PlaybackService.playerCommandsFor(capability)
|
|
314
313
|
|
|
315
314
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
316
315
|
// Cold-wake the JS bridge so the consumer's
|
|
@@ -471,15 +470,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
471
470
|
internal fun refreshAvailableCommands() {
|
|
472
471
|
val handler = commandHandler ?: return
|
|
473
472
|
val session = mediaSession ?: return
|
|
474
|
-
// The cap is global — same Player.Commands for every controller
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
// dashboard tap routes to Library instead of an empty Now Playing.
|
|
478
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
479
|
-
val commands = playerCommandsFor(
|
|
480
|
-
handler.getCachedSkipCapability(),
|
|
481
|
-
hasMediaItems
|
|
482
|
-
)
|
|
473
|
+
// The cap is global — same Player.Commands for every controller;
|
|
474
|
+
// only the four skip commands flip with the queue boundary.
|
|
475
|
+
val commands = playerCommandsFor(handler.getCachedSkipCapability())
|
|
483
476
|
for (controller in session.connectedControllers) {
|
|
484
477
|
session.setAvailableCommands(
|
|
485
478
|
controller,
|
|
@@ -1603,38 +1596,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
1603
1596
|
* from `DEFAULT_PLAYER_COMMANDS`.
|
|
1604
1597
|
*/
|
|
1605
1598
|
internal fun playerCommandsFor(
|
|
1606
|
-
capability: SkipCapability
|
|
1607
|
-
hasMediaItems: Boolean
|
|
1599
|
+
capability: SkipCapability
|
|
1608
1600
|
): Player.Commands {
|
|
1609
1601
|
val builder = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
|
1610
|
-
if (!hasMediaItems) {
|
|
1611
|
-
// Empty queue → strip every command that contributes to the
|
|
1612
|
-
// legacy `PlaybackStateCompat` action mask's PLAY-related bits.
|
|
1613
|
-
// Android Auto's dashboard-tap launch heuristic reads that mask:
|
|
1614
|
-
// STATE_NONE alone is not enough — the action mask must also
|
|
1615
|
-
// carry no ACTION_PLAY / ACTION_PLAY_PAUSE / ACTION_PREPARE.
|
|
1616
|
-
// Once both are zero, AA lands on the Library/Browse surface
|
|
1617
|
-
// instead of "Now Playing — To play something, tap the icon at
|
|
1618
|
-
// the top left."
|
|
1619
|
-
//
|
|
1620
|
-
// This rides Media3 1.10.0's internal Player.Commands →
|
|
1621
|
-
// PlaybackStateCompat action-mask translation, which is not a public
|
|
1622
|
-
// contract — re-validate on a Media3 upgrade. Unit tests cover the
|
|
1623
|
-
// command-strip input; the resulting action mask is verified on the
|
|
1624
|
-
// Android Auto lane, since the legacy bridge only populates for a
|
|
1625
|
-
// connected controller that can't be driven under Robolectric.
|
|
1626
|
-
builder.remove(Player.COMMAND_PLAY_PAUSE)
|
|
1627
|
-
builder.remove(Player.COMMAND_PREPARE)
|
|
1628
|
-
builder.remove(Player.COMMAND_STOP)
|
|
1629
|
-
builder.remove(Player.COMMAND_SEEK_BACK)
|
|
1630
|
-
builder.remove(Player.COMMAND_SEEK_FORWARD)
|
|
1631
|
-
builder.remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
|
|
1632
|
-
builder.remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
|
|
1633
|
-
builder.remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM)
|
|
1634
|
-
builder.remove(Player.COMMAND_SET_SHUFFLE_MODE)
|
|
1635
|
-
builder.remove(Player.COMMAND_SET_REPEAT_MODE)
|
|
1636
|
-
builder.remove(Player.COMMAND_SET_SPEED_AND_PITCH)
|
|
1637
|
-
}
|
|
1638
1602
|
if (!capability.canSkipNext) {
|
|
1639
1603
|
builder.remove(Player.COMMAND_SEEK_TO_NEXT)
|
|
1640
1604
|
builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
|
@@ -56,17 +56,13 @@ internal class PlaybackServiceCallback(
|
|
|
56
56
|
fun mediaButtonDispatch(): MediaButtonDispatch?
|
|
57
57
|
|
|
58
58
|
/** Translate the lib's queue state into the [Player.Commands] set
|
|
59
|
-
* controllers should see.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* dashboard tap route to the Library/Browse surface instead of
|
|
65
|
-
* the empty "Now Playing — To play something" fallback when the
|
|
66
|
-
* queue is empty. */
|
|
59
|
+
* controllers should see. Only the skip pair greys out (the
|
|
60
|
+
* repeat-mode + queue-position arithmetic disallows it); every
|
|
61
|
+
* other command — play/pause, seek, etc. — is preserved so the
|
|
62
|
+
* API 33+ System UI derives the play/pause button + the draggable
|
|
63
|
+
* seek scrubber from the session's transport commands. */
|
|
67
64
|
fun playerCommandsFor(
|
|
68
|
-
capability: SkipCapability
|
|
69
|
-
hasMediaItems: Boolean
|
|
65
|
+
capability: SkipCapability
|
|
70
66
|
): Player.Commands
|
|
71
67
|
|
|
72
68
|
/** Invoked when a controller from a known headless-eligible
|
|
@@ -317,12 +313,8 @@ internal class PlaybackServiceCallback(
|
|
|
317
313
|
)
|
|
318
314
|
val handler = environment.commandHandler()
|
|
319
315
|
if (handler != null) {
|
|
320
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
321
316
|
builder.setAvailablePlayerCommands(
|
|
322
|
-
environment.playerCommandsFor(
|
|
323
|
-
handler.getCachedSkipCapability(),
|
|
324
|
-
hasMediaItems
|
|
325
|
-
)
|
|
317
|
+
environment.playerCommandsFor(handler.getCachedSkipCapability())
|
|
326
318
|
)
|
|
327
319
|
}
|
|
328
320
|
return builder.build()
|
|
@@ -621,17 +621,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
621
621
|
// `emitStateIfChanged` re-label the resulting PAUSED emit as 'sleep-timer'.
|
|
622
622
|
private var pendingSleepTimerPause = false
|
|
623
623
|
|
|
624
|
-
/**
|
|
625
|
-
* Tracks the most recently published "queue has any media items"
|
|
626
|
-
* state so [recomputeCapabilities] can detect empty→non-empty
|
|
627
|
-
* transitions even when the skip-capability pair didn't change
|
|
628
|
-
* (e.g. setQueue replaces an empty queue with a single-track list:
|
|
629
|
-
* skip pair stays `(false, false)` but the action mask must flip
|
|
630
|
-
* to include PLAY again). Initial `false` matches the empty queue
|
|
631
|
-
* the lib starts with.
|
|
632
|
-
*/
|
|
633
|
-
private var lastHasMediaItemsReported: Boolean = false
|
|
634
|
-
|
|
635
624
|
/**
|
|
636
625
|
* Cached classification of the active track's playback source.
|
|
637
626
|
* Recomputed in [handleMediaItemTransition] whenever the active
|
|
@@ -2241,21 +2230,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2241
2230
|
val current = cachedSkipCapability
|
|
2242
2231
|
val capChanged =
|
|
2243
2232
|
newPrev != current.canSkipPrevious || newNext != current.canSkipNext
|
|
2244
|
-
|
|
2245
|
-
val emptyStateChanged = hasMediaItems != lastHasMediaItemsReported
|
|
2246
|
-
if (!capChanged && !emptyStateChanged) return
|
|
2247
|
-
lastHasMediaItemsReported = hasMediaItems
|
|
2233
|
+
if (!capChanged) return
|
|
2248
2234
|
val snapshot = SkipCapability(
|
|
2249
2235
|
canSkipPrevious = newPrev, canSkipNext = newNext)
|
|
2250
2236
|
cachedSkipCapability = snapshot
|
|
2251
|
-
|
|
2252
|
-
// Push the new capability into MediaSession.setAvailableCommands
|
|
2253
|
-
//
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
// strip from `playerCommandsFor(_, hasMediaItems=false)` reaches
|
|
2257
|
-
// gearhead the moment the queue empties (and re-appears the
|
|
2258
|
-
// moment a new queue is set).
|
|
2237
|
+
skipCapabilityListeners.forEach { it(snapshot) }
|
|
2238
|
+
// Push the new capability into MediaSession.setAvailableCommands for
|
|
2239
|
+
// every connected controller so external surfaces (lock-screen,
|
|
2240
|
+
// Bluetooth AVRCP, Android Auto, Wear) grey out skip buttons that the
|
|
2241
|
+
// queue boundary makes invalid.
|
|
2259
2242
|
serviceBinder?.refreshAvailableCommands()
|
|
2260
2243
|
}
|
|
2261
2244
|
|
|
@@ -3460,6 +3443,33 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
3460
3443
|
|
|
3461
3444
|
override fun onActiveItemFormatChanged(engine: PlaybackEngine) {
|
|
3462
3445
|
refreshNowPlayingFormatForActiveItem()
|
|
3446
|
+
backfillActiveItemDurationIfNeeded(engine)
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
/**
|
|
3450
|
+
* Write `METADATA_KEY_DURATION` onto the current item from the player's
|
|
3451
|
+
* decoded duration when the consumer didn't supply one, so the API 33+
|
|
3452
|
+
* System UI seekbar has a real total time. One-shot per item — once the
|
|
3453
|
+
* item carries a positive duration, [MediaItemBuilder.durationToBackfill]
|
|
3454
|
+
* returns null, so re-fires (and the `onTracksChanged` re-entrancy the
|
|
3455
|
+
* `replaceMediaItem` triggers) are no-ops. Skipped mid-crossfade so the
|
|
3456
|
+
* outgoing leg isn't rewritten during a fade; the incoming item backfills
|
|
3457
|
+
* once it becomes leading and this callback re-fires. Main thread.
|
|
3458
|
+
*/
|
|
3459
|
+
@MainThread
|
|
3460
|
+
private fun backfillActiveItemDurationIfNeeded(engine: PlaybackEngine) {
|
|
3461
|
+
if (engine.isCrossfadeActive) return
|
|
3462
|
+
val p = player ?: return
|
|
3463
|
+
val idx = p.currentMediaItemIndex
|
|
3464
|
+
if (idx < 0) return
|
|
3465
|
+
val item = p.currentMediaItem ?: return
|
|
3466
|
+
val dur = MediaItemBuilder.durationToBackfill(
|
|
3467
|
+
item.mediaMetadata.durationMs, p.duration
|
|
3468
|
+
) ?: return
|
|
3469
|
+
val updated = item.buildUpon()
|
|
3470
|
+
.setMediaMetadata(item.mediaMetadata.buildUpon().setDurationMs(dur).build())
|
|
3471
|
+
.build()
|
|
3472
|
+
p.replaceMediaItem(idx, updated)
|
|
3463
3473
|
}
|
|
3464
3474
|
|
|
3465
3475
|
override fun onError(engine: PlaybackEngine, exception: PlaybackException) {
|
|
@@ -259,6 +259,7 @@ private class FakePlaybackEngine : PlaybackEngine {
|
|
|
259
259
|
override val bufferedPositionMs: Long get() = error("not used in test")
|
|
260
260
|
override val isPlaying: Boolean get() = error("not used in test")
|
|
261
261
|
override val currentMediaItem: androidx.media3.common.MediaItem? get() = error("not used in test")
|
|
262
|
+
override val isCrossfadeActive: Boolean get() = false
|
|
262
263
|
override val allMediaItems: List<androidx.media3.common.MediaItem> get() = error("not used in test")
|
|
263
264
|
override fun currentAudioSessionIds(): IntArray = IntArray(0)
|
|
264
265
|
override fun addAudioSessionIdListener(listener: (IntArray) -> Unit): () -> Unit {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.margelo.nitro.queueplayer
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import androidx.media3.common.C
|
|
4
5
|
import androidx.media3.common.MediaMetadata
|
|
5
6
|
import androidx.media3.common.util.UnstableApi
|
|
6
7
|
import androidx.test.core.app.ApplicationProvider
|
|
@@ -366,6 +367,41 @@ class MediaItemBuilderTest {
|
|
|
366
367
|
private val context: Context
|
|
367
368
|
get() = ApplicationProvider.getApplicationContext()
|
|
368
369
|
|
|
370
|
+
// --- durationToBackfill: the System UI seekbar duration derivation decision
|
|
371
|
+
|
|
372
|
+
@Test
|
|
373
|
+
fun `durationToBackfill keeps a positive consumer duration and never overwrites`() {
|
|
374
|
+
assertNull(
|
|
375
|
+
MediaItemBuilder.durationToBackfill(existingDurationMs = 42_000L, playerDurationMs = 50_000L),
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@Test
|
|
380
|
+
fun `durationToBackfill derives from the player when the consumer supplied none`() {
|
|
381
|
+
assertEquals(
|
|
382
|
+
180_000L,
|
|
383
|
+
MediaItemBuilder.durationToBackfill(existingDurationMs = null, playerDurationMs = 180_000L),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
@Test
|
|
388
|
+
fun `durationToBackfill treats a non-positive existing duration as absent`() {
|
|
389
|
+
assertEquals(
|
|
390
|
+
180_000L,
|
|
391
|
+
MediaItemBuilder.durationToBackfill(existingDurationMs = 0L, playerDurationMs = 180_000L),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
@Test
|
|
396
|
+
fun `durationToBackfill skips a live or not-yet-decoded player duration`() {
|
|
397
|
+
assertNull(
|
|
398
|
+
MediaItemBuilder.durationToBackfill(existingDurationMs = null, playerDurationMs = C.TIME_UNSET),
|
|
399
|
+
)
|
|
400
|
+
assertNull(
|
|
401
|
+
MediaItemBuilder.durationToBackfill(existingDurationMs = null, playerDurationMs = 0L),
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
369
405
|
private fun makeTrack(
|
|
370
406
|
id: String,
|
|
371
407
|
url: String,
|
|
@@ -106,6 +106,7 @@ class PlaybackEngineInterfaceTest {
|
|
|
106
106
|
override val bufferedPositionMs: Long = 0
|
|
107
107
|
override val isPlaying: Boolean = false
|
|
108
108
|
override val currentMediaItem: MediaItem? = null
|
|
109
|
+
override val isCrossfadeActive: Boolean = false
|
|
109
110
|
override val allMediaItems: List<MediaItem> = emptyList()
|
|
110
111
|
|
|
111
112
|
override fun currentAudioSessionIds(): IntArray = IntArray(0)
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt
CHANGED
|
@@ -278,10 +278,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
278
278
|
override fun commandHandler() = null
|
|
279
279
|
override fun mediaButtonDispatch() = null
|
|
280
280
|
override fun playerCommandsFor(
|
|
281
|
-
capability: SkipCapability
|
|
282
|
-
hasMediaItems: Boolean
|
|
281
|
+
capability: SkipCapability
|
|
283
282
|
): Player.Commands =
|
|
284
|
-
service.playerCommandsFor(capability
|
|
283
|
+
service.playerCommandsFor(capability)
|
|
285
284
|
override fun onBrowseRequestForChildren(
|
|
286
285
|
parentId: String
|
|
287
286
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -309,10 +308,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
309
308
|
override fun commandHandler() = null
|
|
310
309
|
override fun mediaButtonDispatch() = null
|
|
311
310
|
override fun playerCommandsFor(
|
|
312
|
-
capability: SkipCapability
|
|
313
|
-
hasMediaItems: Boolean
|
|
311
|
+
capability: SkipCapability
|
|
314
312
|
): Player.Commands =
|
|
315
|
-
service.playerCommandsFor(capability
|
|
313
|
+
service.playerCommandsFor(capability)
|
|
316
314
|
override fun onBrowseRequestForChildren(
|
|
317
315
|
parentId: String
|
|
318
316
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -438,10 +436,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
438
436
|
override fun commandHandler() = null
|
|
439
437
|
override fun mediaButtonDispatch() = null
|
|
440
438
|
override fun playerCommandsFor(
|
|
441
|
-
capability: SkipCapability
|
|
442
|
-
hasMediaItems: Boolean
|
|
439
|
+
capability: SkipCapability
|
|
443
440
|
): Player.Commands =
|
|
444
|
-
service.playerCommandsFor(capability
|
|
441
|
+
service.playerCommandsFor(capability)
|
|
445
442
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
446
443
|
called = packageName
|
|
447
444
|
}
|
|
@@ -462,10 +459,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
462
459
|
override fun commandHandler() = null
|
|
463
460
|
override fun mediaButtonDispatch() = null
|
|
464
461
|
override fun playerCommandsFor(
|
|
465
|
-
capability: SkipCapability
|
|
466
|
-
hasMediaItems: Boolean
|
|
462
|
+
capability: SkipCapability
|
|
467
463
|
): Player.Commands =
|
|
468
|
-
service.playerCommandsFor(capability
|
|
464
|
+
service.playerCommandsFor(capability)
|
|
469
465
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
470
466
|
called = packageName
|
|
471
467
|
}
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt
CHANGED
|
@@ -68,10 +68,9 @@ class PlaybackServiceCallbackSearchTest {
|
|
|
68
68
|
override fun commandHandler() = null
|
|
69
69
|
override fun mediaButtonDispatch() = null
|
|
70
70
|
override fun playerCommandsFor(
|
|
71
|
-
capability: SkipCapability
|
|
72
|
-
hasMediaItems: Boolean
|
|
71
|
+
capability: SkipCapability
|
|
73
72
|
): Player.Commands =
|
|
74
|
-
service.playerCommandsFor(capability
|
|
73
|
+
service.playerCommandsFor(capability)
|
|
75
74
|
override fun onSearchQueryReceived(query: String) {
|
|
76
75
|
received.add(query)
|
|
77
76
|
}
|
|
@@ -227,8 +227,7 @@ class PlaybackServiceCallbackTest {
|
|
|
227
227
|
@Test
|
|
228
228
|
fun `playerCommandsFor strips both skips when capability disallows both`() {
|
|
229
229
|
val commands = service.playerCommandsFor(
|
|
230
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
231
|
-
hasMediaItems = true
|
|
230
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
232
231
|
)
|
|
233
232
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
234
233
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -243,23 +242,21 @@ class PlaybackServiceCallbackTest {
|
|
|
243
242
|
}
|
|
244
243
|
|
|
245
244
|
@Test
|
|
246
|
-
fun `playerCommandsFor
|
|
247
|
-
//
|
|
248
|
-
//
|
|
245
|
+
fun `playerCommandsFor keeps transport commands when queue is empty`() {
|
|
246
|
+
// Transport commands stay available so the API 33+ System UI keeps the
|
|
247
|
+
// play/pause button + the draggable seek scrubber; only the skip pair
|
|
248
|
+
// greys out with the queue boundary.
|
|
249
249
|
val commands = service.playerCommandsFor(
|
|
250
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
251
|
-
hasMediaItems = false
|
|
250
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
252
251
|
)
|
|
253
|
-
|
|
254
|
-
"
|
|
252
|
+
assertTrue(
|
|
253
|
+
"play/pause is retained for the System UI play/pause button",
|
|
255
254
|
commands.contains(Player.COMMAND_PLAY_PAUSE)
|
|
256
255
|
)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assertFalse(commands.contains(Player.COMMAND_SET_REPEAT_MODE))
|
|
262
|
-
assertFalse(commands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
|
|
256
|
+
assertTrue(commands.contains(Player.COMMAND_PREPARE))
|
|
257
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
|
|
258
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
|
|
259
|
+
assertTrue(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
|
|
263
260
|
}
|
|
264
261
|
|
|
265
262
|
@Test
|
|
@@ -342,7 +339,7 @@ class PlaybackServiceCallbackTest {
|
|
|
342
339
|
// The handler's value is what `PlaybackCallback.onConnect` reads,
|
|
343
340
|
// so a fresh `onConnect` against any session sharing this handler
|
|
344
341
|
// sees the post-flip command set.
|
|
345
|
-
val playerCommands = service.playerCommandsFor(mid
|
|
342
|
+
val playerCommands = service.playerCommandsFor(mid)
|
|
346
343
|
assertTrue(playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
347
344
|
assertFalse(playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
348
345
|
} finally {
|
|
@@ -876,10 +873,9 @@ class PlaybackServiceCallbackTest {
|
|
|
876
873
|
override fun mediaButtonDispatch(): MediaButtonDispatch? = null
|
|
877
874
|
|
|
878
875
|
override fun playerCommandsFor(
|
|
879
|
-
capability: SkipCapability
|
|
880
|
-
hasMediaItems: Boolean
|
|
876
|
+
capability: SkipCapability
|
|
881
877
|
): Player.Commands =
|
|
882
|
-
service.playerCommandsFor(capability
|
|
878
|
+
service.playerCommandsFor(capability)
|
|
883
879
|
|
|
884
880
|
override fun onPlayFromSearchRequest(
|
|
885
881
|
request: MediaSearchRequest
|
|
@@ -65,7 +65,6 @@ class PlayerCommandsForTest {
|
|
|
65
65
|
fun `commands include every skip pair when capability allows both`() {
|
|
66
66
|
val commands = service.playerCommandsFor(
|
|
67
67
|
SkipCapability(canSkipPrevious = true, canSkipNext = true),
|
|
68
|
-
hasMediaItems = true,
|
|
69
68
|
)
|
|
70
69
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
71
70
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -78,7 +77,6 @@ class PlayerCommandsForTest {
|
|
|
78
77
|
fun `commands omit only the next pair when capability disallows skip-next`() {
|
|
79
78
|
val commands = service.playerCommandsFor(
|
|
80
79
|
SkipCapability(canSkipPrevious = true, canSkipNext = false),
|
|
81
|
-
hasMediaItems = true,
|
|
82
80
|
)
|
|
83
81
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
84
82
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -91,7 +89,6 @@ class PlayerCommandsForTest {
|
|
|
91
89
|
fun `commands omit only the previous pair when capability disallows skip-previous`() {
|
|
92
90
|
val commands = service.playerCommandsFor(
|
|
93
91
|
SkipCapability(canSkipPrevious = false, canSkipNext = true),
|
|
94
|
-
hasMediaItems = true,
|
|
95
92
|
)
|
|
96
93
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
97
94
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -104,7 +101,6 @@ class PlayerCommandsForTest {
|
|
|
104
101
|
fun `commands omit both skip pairs when capability disallows both`() {
|
|
105
102
|
val commands = service.playerCommandsFor(
|
|
106
103
|
SkipCapability(canSkipPrevious = false, canSkipNext = false),
|
|
107
|
-
hasMediaItems = true,
|
|
108
104
|
)
|
|
109
105
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
110
106
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -119,7 +115,6 @@ class PlayerCommandsForTest {
|
|
|
119
115
|
fun `repeat OFF at index 0 of a two-track queue exposes only skip-next`() {
|
|
120
116
|
val commands = service.playerCommandsFor(
|
|
121
117
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 0),
|
|
122
|
-
hasMediaItems = true,
|
|
123
118
|
)
|
|
124
119
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
125
120
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -129,7 +124,6 @@ class PlayerCommandsForTest {
|
|
|
129
124
|
fun `repeat OFF at the last index of a two-track queue exposes only skip-previous`() {
|
|
130
125
|
val commands = service.playerCommandsFor(
|
|
131
126
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 1),
|
|
132
|
-
hasMediaItems = true,
|
|
133
127
|
)
|
|
134
128
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
135
129
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -139,7 +133,6 @@ class PlayerCommandsForTest {
|
|
|
139
133
|
fun `repeat OFF on a single-track queue exposes neither skip command`() {
|
|
140
134
|
val commands = service.playerCommandsFor(
|
|
141
135
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 1, current = 0),
|
|
142
|
-
hasMediaItems = true,
|
|
143
136
|
)
|
|
144
137
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
145
138
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -149,15 +142,12 @@ class PlayerCommandsForTest {
|
|
|
149
142
|
fun `repeat QUEUE wraps so both skip commands stay enabled at every queue position`() {
|
|
150
143
|
val first = service.playerCommandsFor(
|
|
151
144
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 0),
|
|
152
|
-
hasMediaItems = true,
|
|
153
145
|
)
|
|
154
146
|
val mid = service.playerCommandsFor(
|
|
155
147
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 1),
|
|
156
|
-
hasMediaItems = true,
|
|
157
148
|
)
|
|
158
149
|
val last = service.playerCommandsFor(
|
|
159
150
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 2),
|
|
160
|
-
hasMediaItems = true,
|
|
161
151
|
)
|
|
162
152
|
for (commands in listOf(first, mid, last)) {
|
|
163
153
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -170,7 +160,6 @@ class PlayerCommandsForTest {
|
|
|
170
160
|
// First track of 2: next available, no previous (no wrap).
|
|
171
161
|
val atFirst = service.playerCommandsFor(
|
|
172
162
|
capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 0),
|
|
173
|
-
hasMediaItems = true,
|
|
174
163
|
)
|
|
175
164
|
assertTrue(atFirst.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
176
165
|
assertFalse(atFirst.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -178,31 +167,33 @@ class PlayerCommandsForTest {
|
|
|
178
167
|
// Last track of 2: previous available, no next (no wrap).
|
|
179
168
|
val atLast = service.playerCommandsFor(
|
|
180
169
|
capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 1),
|
|
181
|
-
hasMediaItems = true,
|
|
182
170
|
)
|
|
183
171
|
assertFalse(atLast.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
184
172
|
assertTrue(atLast.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
185
173
|
}
|
|
186
174
|
|
|
187
175
|
@Test
|
|
188
|
-
fun `empty queue
|
|
176
|
+
fun `empty queue disables skip but keeps transport commands for the System UI`() {
|
|
189
177
|
for (mode in QueueSkipRepeatMode.entries) {
|
|
190
178
|
val commands = service.playerCommandsFor(
|
|
191
179
|
capabilityFor(repeat = mode, count = 0, current = -1),
|
|
192
|
-
hasMediaItems = false,
|
|
193
180
|
)
|
|
194
181
|
assertFalse(
|
|
195
|
-
"skip-next is
|
|
182
|
+
"skip-next is disabled with no tracks (repeat=$mode)",
|
|
196
183
|
commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM),
|
|
197
184
|
)
|
|
198
185
|
assertFalse(
|
|
199
|
-
"skip-previous is
|
|
186
|
+
"skip-previous is disabled with no tracks (repeat=$mode)",
|
|
200
187
|
commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
|
|
201
188
|
)
|
|
202
|
-
|
|
203
|
-
"
|
|
189
|
+
assertTrue(
|
|
190
|
+
"play/pause is retained so the API 33+ System UI keeps the button (repeat=$mode)",
|
|
204
191
|
commands.contains(Player.COMMAND_PLAY_PAUSE),
|
|
205
192
|
)
|
|
193
|
+
assertTrue(
|
|
194
|
+
"in-item seek is retained so the seek scrubber stays draggable (repeat=$mode)",
|
|
195
|
+
commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM),
|
|
196
|
+
)
|
|
206
197
|
}
|
|
207
198
|
}
|
|
208
199
|
|
|
@@ -187,6 +187,7 @@ private class FakePlaybackEngineWithSinks : PlaybackEngine {
|
|
|
187
187
|
override val bufferedPositionMs: Long get() = error("not used in test")
|
|
188
188
|
override val isPlaying: Boolean get() = error("not used in test")
|
|
189
189
|
override val currentMediaItem: androidx.media3.common.MediaItem? get() = error("not used in test")
|
|
190
|
+
override val isCrossfadeActive: Boolean get() = false
|
|
190
191
|
override val allMediaItems: List<androidx.media3.common.MediaItem> get() = error("not used in test")
|
|
191
192
|
override fun currentAudioSessionIds(): IntArray = IntArray(0)
|
|
192
193
|
override fun addAudioSessionIdListener(listener: (IntArray) -> Unit): () -> Unit = { /* noop */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-queue-player",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/index.d.ts",
|