react-native-queue-player 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +6 -42
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +7 -15
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +72 -23
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +15 -19
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
  18. package/ios/AVQueueBuilder.swift +29 -0
  19. package/ios/QueueState.swift +14 -2
  20. package/ios/Tests/AVQueueBuilderTests.swift +74 -0
  21. package/ios/Tests/QueueStateTests.swift +50 -0
  22. package/ios/TrackPlayer.swift +121 -14
  23. package/lib/module/hooks/index.js +1 -0
  24. package/lib/module/hooks/index.js.map +1 -1
  25. package/lib/module/hooks/useQueue.js +57 -0
  26. package/lib/module/hooks/useQueue.js.map +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/types.js +7 -0
  29. package/lib/module/types.js.map +1 -1
  30. package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
  31. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  32. package/lib/typescript/hooks/index.d.ts +2 -0
  33. package/lib/typescript/hooks/index.d.ts.map +1 -1
  34. package/lib/typescript/hooks/useQueue.d.ts +20 -0
  35. package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
  36. package/lib/typescript/index.d.ts +1 -1
  37. package/lib/typescript/index.d.ts.map +1 -1
  38. package/lib/typescript/types.d.ts +5 -0
  39. package/lib/typescript/types.d.ts.map +1 -1
  40. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
  41. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
  42. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  43. package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
  47. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
  48. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
  49. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
  50. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  51. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
  52. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
  53. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  54. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
  55. package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
  56. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  57. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  58. package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
  59. package/package.json +1 -1
  60. package/src/TrackPlayer.nitro.ts +33 -0
  61. package/src/hooks/index.ts +3 -0
  62. package/src/hooks/useQueue.ts +56 -0
  63. package/src/index.ts +1 -0
  64. package/src/types.ts +12 -0
@@ -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()
@@ -544,6 +544,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
544
544
  // simultaneously without clobbering each other.
545
545
  private val stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Unit>()
546
546
  private val trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Unit>()
547
+ private val queueChangeListeners = ListenerRegistry<(Array<TrackItem>, Double, QueueChangeReason) -> Unit>()
547
548
  private val progressListeners = ListenerRegistry<(PlayerProgress) -> Unit>()
548
549
  // Buffer state — discrete empty/buffering/stalled/full for the active track, derived
549
550
  // from Media3's playback state + buffered-ahead. Recomputed on the engine
@@ -621,17 +622,6 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
621
622
  // `emitStateIfChanged` re-label the resulting PAUSED emit as 'sleep-timer'.
622
623
  private var pendingSleepTimerPause = false
623
624
 
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
625
  /**
636
626
  * Cached classification of the active track's playback source.
637
627
  * Recomputed in [handleMediaItemTransition] whenever the active
@@ -1456,6 +1446,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1456
1446
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1457
1447
  rescheduleLookahead()
1458
1448
  recomputeCapabilities()
1449
+ emitQueueChange(QueueChangeReason.SET_QUEUE)
1459
1450
  }
1460
1451
 
1461
1452
  /**
@@ -1514,6 +1505,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1514
1505
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1515
1506
  rescheduleLookahead()
1516
1507
  recomputeCapabilities()
1508
+ emitQueueChange(QueueChangeReason.SET_QUEUE)
1517
1509
  return items
1518
1510
  }
1519
1511
 
@@ -1606,6 +1598,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1606
1598
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1607
1599
  rescheduleLookahead()
1608
1600
  recomputeCapabilities()
1601
+ emitQueueChange(QueueChangeReason.ADD)
1609
1602
  return at
1610
1603
  }
1611
1604
 
@@ -1699,6 +1692,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1699
1692
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1700
1693
  rescheduleLookahead()
1701
1694
  recomputeCapabilities()
1695
+ // Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
1696
+ // removed; tracks + index are final.
1697
+ emitQueueChange(QueueChangeReason.REMOVE)
1702
1698
  return sanitised
1703
1699
  }
1704
1700
 
@@ -1764,6 +1760,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1764
1760
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1765
1761
  rescheduleLookahead()
1766
1762
  recomputeCapabilities()
1763
+ // Reached only past the invalid / `from == to` / pinned-current
1764
+ // guards, so the order actually changed.
1765
+ emitQueueChange(QueueChangeReason.MOVE)
1767
1766
  return true
1768
1767
  }
1769
1768
 
@@ -1782,6 +1781,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1782
1781
  @VisibleForTesting
1783
1782
  internal fun clearQueueInternal() {
1784
1783
  val p = player ?: return
1784
+ val queueWasNonEmpty = tracks.isNotEmpty()
1785
1785
  // Reset authoritative state BEFORE the ExoPlayer mutation.
1786
1786
  // `clearMediaItems()` fires `onMediaItemTransition(null,
1787
1787
  // REASON_PLAYLIST_CHANGED)` synchronously, which routes through
@@ -1799,6 +1799,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1799
1799
  retryAttemptsRemaining.clear()
1800
1800
  tracks = emptyList()
1801
1801
  currentTrackIndex = -1
1802
+ // Fire queue-change BEFORE setMediaItems (which synchronously fires
1803
+ // onTrackChange via onMediaItemTransition) so a `useQueue()` consumer
1804
+ // sees the empty queue + index -1 together. Only when the queue was
1805
+ // actually non-empty — clearing an empty queue is a no-op.
1806
+ if (queueWasNonEmpty) emitQueueChange(QueueChangeReason.CLEAR)
1802
1807
  // Route through the engine surface (not directly through the
1803
1808
  // leading leg) so CrossfadeEngine can also tear down the
1804
1809
  // standby leg's preroll'd items + cancel any in-flight fade.
@@ -2241,21 +2246,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2241
2246
  val current = cachedSkipCapability
2242
2247
  val capChanged =
2243
2248
  newPrev != current.canSkipPrevious || newNext != current.canSkipNext
2244
- val hasMediaItems = count > 0
2245
- val emptyStateChanged = hasMediaItems != lastHasMediaItemsReported
2246
- if (!capChanged && !emptyStateChanged) return
2247
- lastHasMediaItemsReported = hasMediaItems
2249
+ if (!capChanged) return
2248
2250
  val snapshot = SkipCapability(
2249
2251
  canSkipPrevious = newPrev, canSkipNext = newNext)
2250
2252
  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).
2253
+ skipCapabilityListeners.forEach { it(snapshot) }
2254
+ // Push the new capability into MediaSession.setAvailableCommands for
2255
+ // every connected controller so external surfaces (lock-screen,
2256
+ // Bluetooth AVRCP, Android Auto, Wear) grey out skip buttons that the
2257
+ // queue boundary makes invalid.
2259
2258
  serviceBinder?.refreshAvailableCommands()
2260
2259
  }
2261
2260
 
@@ -2819,6 +2818,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2819
2818
 
2820
2819
  rescheduleLookahead()
2821
2820
  recomputeCapabilities()
2821
+ // Skip a single-track shuffle — the order can't change.
2822
+ if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
2822
2823
 
2823
2824
  return ShuffleResult(
2824
2825
  tracks = newTracks.toTypedArray(),
@@ -2909,6 +2910,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2909
2910
  return { trackChangeListeners.remove(id) }
2910
2911
  }
2911
2912
 
2913
+ override fun onQueueChange(
2914
+ callback: (queue: Array<TrackItem>, currentIndex: Double, reason: QueueChangeReason) -> Unit
2915
+ ): () -> Unit {
2916
+ val id = queueChangeListeners.add(callback)
2917
+ return { queueChangeListeners.remove(id) }
2918
+ }
2919
+
2920
+ /**
2921
+ * Fire onQueueChange with an atomic snapshot of the post-mutation
2922
+ * queue + current index. Main-thread only — every queue mutation
2923
+ * commits on main, so [tracks] / [currentTrackIndex] are settled here.
2924
+ * Callers gate this on an actual content/order change so a no-op
2925
+ * mutation never emits.
2926
+ */
2927
+ @MainThread
2928
+ private fun emitQueueChange(reason: QueueChangeReason) {
2929
+ val snapshot = tracks.toTypedArray()
2930
+ val index = currentTrackIndex.toDouble()
2931
+ queueChangeListeners.forEach { it(snapshot, index, reason) }
2932
+ }
2933
+
2912
2934
  override fun onProgress(
2913
2935
  callback: (progress: PlayerProgress) -> Unit
2914
2936
  ): () -> Unit {
@@ -3460,6 +3482,33 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3460
3482
 
3461
3483
  override fun onActiveItemFormatChanged(engine: PlaybackEngine) {
3462
3484
  refreshNowPlayingFormatForActiveItem()
3485
+ backfillActiveItemDurationIfNeeded(engine)
3486
+ }
3487
+
3488
+ /**
3489
+ * Write `METADATA_KEY_DURATION` onto the current item from the player's
3490
+ * decoded duration when the consumer didn't supply one, so the API 33+
3491
+ * System UI seekbar has a real total time. One-shot per item — once the
3492
+ * item carries a positive duration, [MediaItemBuilder.durationToBackfill]
3493
+ * returns null, so re-fires (and the `onTracksChanged` re-entrancy the
3494
+ * `replaceMediaItem` triggers) are no-ops. Skipped mid-crossfade so the
3495
+ * outgoing leg isn't rewritten during a fade; the incoming item backfills
3496
+ * once it becomes leading and this callback re-fires. Main thread.
3497
+ */
3498
+ @MainThread
3499
+ private fun backfillActiveItemDurationIfNeeded(engine: PlaybackEngine) {
3500
+ if (engine.isCrossfadeActive) return
3501
+ val p = player ?: return
3502
+ val idx = p.currentMediaItemIndex
3503
+ if (idx < 0) return
3504
+ val item = p.currentMediaItem ?: return
3505
+ val dur = MediaItemBuilder.durationToBackfill(
3506
+ item.mediaMetadata.durationMs, p.duration
3507
+ ) ?: return
3508
+ val updated = item.buildUpon()
3509
+ .setMediaMetadata(item.mediaMetadata.buildUpon().setDurationMs(dur).build())
3510
+ .build()
3511
+ p.replaceMediaItem(idx, updated)
3463
3512
  }
3464
3513
 
3465
3514
  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