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.
@@ -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, hasMediaItems)
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
- // `hasMediaItems` reads from the live player so a freshly-empty
476
- // queue strips PLAY/PAUSE/PREPARE from the action mask and AA's
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. The skip pair greys out skip buttons
60
- * that the repeat-mode + queue-position arithmetic disallows;
61
- * `hasMediaItems = false` additionally strips play / pause /
62
- * prepare so that the legacy `PlaybackStateCompat` action mask
63
- * carries no PLAY bit. That signal is what makes Android Auto's
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
- val hasMediaItems = count > 0
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
- if (capChanged) skipCapabilityListeners.forEach { it(snapshot) }
2252
- // Push the new capability into MediaSession.setAvailableCommands
2253
- // for every connected controller so external surfaces (lock-
2254
- // screen, Bluetooth AVRCP, Auto, Wear) grey out skip buttons
2255
- // that the queue boundary makes invalid, and so the PLAY-bit
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)
@@ -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, hasMediaItems)
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, hasMediaItems)
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, hasMediaItems)
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, hasMediaItems)
464
+ service.playerCommandsFor(capability)
469
465
  override fun onHeadlessControllerConnect(packageName: String) {
470
466
  called = packageName
471
467
  }
@@ -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, hasMediaItems)
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 strips PLAY-related commands when queue is empty`() {
247
- // Empty queue action mask must carry no ACTION_PLAY bits so
248
- // AA's dashboard tap routes to Library rather than Now Playing.
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
- assertFalse(
254
- "PLAY_PAUSE drives the legacy ACTION_PLAY bit; must be absent on empty queue",
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
- assertFalse(commands.contains(Player.COMMAND_PREPARE))
258
- assertFalse(commands.contains(Player.COMMAND_STOP))
259
- assertFalse(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
260
- assertFalse(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
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, hasMediaItems = true)
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, hasMediaItems)
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 exposes neither skip command regardless of repeat mode`() {
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 always disabled with no tracks (repeat=$mode)",
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 always disabled with no tracks (repeat=$mode)",
186
+ "skip-previous is disabled with no tracks (repeat=$mode)",
200
187
  commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
201
188
  )
202
- assertFalse(
203
- "PLAY_PAUSE is stripped on empty queue so AA lands on Library (repeat=$mode)",
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.6",
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",