react-native-queue-player 1.0.4 → 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.
Files changed (32) 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/NotificationProvider.kt +6 -0
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
  11. package/android/src/main/res/values/strings.xml +0 -3
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
  25. package/ios/CarPlayCoordinator.swift +10 -0
  26. package/ios/CarPlayQueueBuilder.swift +82 -0
  27. package/ios/CarPlayQueueProvider.swift +73 -0
  28. package/ios/CarPlaySceneDelegate.swift +79 -7
  29. package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
  30. package/ios/Tests/FFTProcessorTests.swift +9 -7
  31. package/ios/TrackPlayer.swift +47 -0
  32. 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
@@ -18,6 +18,12 @@ import androidx.media3.session.DefaultMediaNotificationProvider
18
18
  * `setMediaButtonPreferences(...)` builder call; this provider reads
19
19
  * those preferences directly.
20
20
  *
21
+ * This provider only pins the channel + notification id — it does NOT
22
+ * restyle transport buttons. On API 33+ the System UI media control is
23
+ * derived from the media session, so `MediaNotification.Provider` layout
24
+ * changes take effect only pre-33; transport customization belongs in the
25
+ * session's available commands + media button preferences, never here.
26
+ *
21
27
  * Artwork loading is wired separately on the [androidx.media3.session.MediaSession]
22
28
  * via `setBitmapLoader(...)` — the provider routes artwork requests
23
29
  * through that loader chain
@@ -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,
@@ -788,20 +781,18 @@ class PlaybackService : HeadlessJsMediaService() {
788
781
  )
789
782
 
790
783
  /**
791
- * The compact notification view shows three buttons in priority
792
- * order: prev / play-pause / next. Media3 1.9+ resolves these from
793
- * standard player commands set via [CommandButton.Builder.setPlayerCommand]
794
- * no custom command handlers required. The expanded view shows
795
- * the same three buttons plus the scrub progress bar that
796
- * `DefaultMediaNotificationProvider` adds automatically when the
797
- * session reports a known duration.
784
+ * The back + forward buttons for the notification compact view and the
785
+ * Android Auto control row. Play/pause is intentionally NOT supplied:
786
+ * Media3 owns the central slot (`CommandButton.SLOT_CENTRAL`) and
787
+ * synthesizes a state-aware play/pause from `COMMAND_PLAY_PAUSE`. A
788
+ * caller-supplied central button is dropped on the notification path
789
+ * (`DefaultMediaNotificationProvider` builds its own play/pause) and on
790
+ * Android Auto forces a static, non-toggling icon — so supplying only
791
+ * back/forward lets Media3 render the toggling play/pause on both
792
+ * surfaces. The expanded view adds the scrub progress bar automatically
793
+ * when the session reports a known duration.
798
794
  */
799
795
  private fun buildMediaButtonPreferences(): List<CommandButton> {
800
- val playPause = CommandButton.Builder(CommandButton.ICON_PLAY)
801
- .setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
802
- .setSlots(CommandButton.SLOT_CENTRAL)
803
- .setDisplayName(getString(R.string.rnqp_action_play_pause))
804
- .build()
805
796
  // In `interval` mode the back/forward slots jump within the current track
806
797
  // (COMMAND_SEEK_BACK / COMMAND_SEEK_FORWARD, honored by
807
798
  // [SessionCommandForwardingPlayer] with the runtime interval); in `track`
@@ -813,7 +804,6 @@ class PlaybackService : HeadlessJsMediaService() {
813
804
  .setSlots(CommandButton.SLOT_BACK)
814
805
  .setDisplayName(getString(R.string.rnqp_action_jump_backward))
815
806
  .build(),
816
- playPause,
817
807
  CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD)
818
808
  .setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
819
809
  .setSlots(CommandButton.SLOT_FORWARD)
@@ -826,7 +816,6 @@ class PlaybackService : HeadlessJsMediaService() {
826
816
  .setSlots(CommandButton.SLOT_BACK)
827
817
  .setDisplayName(getString(R.string.rnqp_action_previous))
828
818
  .build(),
829
- playPause,
830
819
  CommandButton.Builder(CommandButton.ICON_NEXT)
831
820
  .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
832
821
  .setSlots(CommandButton.SLOT_FORWARD)
@@ -1607,31 +1596,9 @@ class PlaybackService : HeadlessJsMediaService() {
1607
1596
  * from `DEFAULT_PLAYER_COMMANDS`.
1608
1597
  */
1609
1598
  internal fun playerCommandsFor(
1610
- capability: SkipCapability,
1611
- hasMediaItems: Boolean
1599
+ capability: SkipCapability
1612
1600
  ): Player.Commands {
1613
1601
  val builder = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
1614
- if (!hasMediaItems) {
1615
- // Empty queue → strip every command that contributes to the
1616
- // legacy `PlaybackStateCompat` action mask's PLAY-related bits.
1617
- // Android Auto's dashboard-tap launch heuristic reads that mask:
1618
- // STATE_NONE alone is not enough — the action mask must also
1619
- // carry no ACTION_PLAY / ACTION_PLAY_PAUSE / ACTION_PREPARE.
1620
- // Once both are zero, AA lands on the Library/Browse surface
1621
- // instead of "Now Playing — To play something, tap the icon at
1622
- // the top left."
1623
- builder.remove(Player.COMMAND_PLAY_PAUSE)
1624
- builder.remove(Player.COMMAND_PREPARE)
1625
- builder.remove(Player.COMMAND_STOP)
1626
- builder.remove(Player.COMMAND_SEEK_BACK)
1627
- builder.remove(Player.COMMAND_SEEK_FORWARD)
1628
- builder.remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
1629
- builder.remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
1630
- builder.remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM)
1631
- builder.remove(Player.COMMAND_SET_SHUFFLE_MODE)
1632
- builder.remove(Player.COMMAND_SET_REPEAT_MODE)
1633
- builder.remove(Player.COMMAND_SET_SPEED_AND_PITCH)
1634
- }
1635
1602
  if (!capability.canSkipNext) {
1636
1603
  builder.remove(Player.COMMAND_SEEK_TO_NEXT)
1637
1604
  builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@@ -1646,11 +1613,11 @@ class PlaybackService : HeadlessJsMediaService() {
1646
1613
  /**
1647
1614
  * Hook the [PlaybackCallback] consults before letting Media3 route
1648
1615
  * a controller-initiated command to the bound player. Calls run on
1649
- * the service main looper. Skip-next / skip-previous return true
1650
- * when the implementation has handled the skip itself, so the
1651
- * default routing is suppressed; the transport hook fires for play /
1652
- * pause / seek / setPlaybackSpeed and is purely a side-channel
1653
- * notification (no return value).
1616
+ * the service main looper. Skip-next / skip-previous / seek-to-queue-item
1617
+ * return true when the implementation has handled the command itself,
1618
+ * so the default routing is suppressed; the transport hook fires for
1619
+ * play / pause / in-item seek / setPlaybackSpeed and is purely a
1620
+ * side-channel notification (no return value).
1654
1621
  */
1655
1622
  internal interface MediaSessionCommandHandler {
1656
1623
  /** Stamps `system` on the state-change channel for the upcoming
@@ -1662,6 +1629,12 @@ class PlaybackService : HeadlessJsMediaService() {
1662
1629
  /** Returns true if the lib intercepted the skip and the default
1663
1630
  * routing should be suppressed. */
1664
1631
  fun onSkipToPreviousCommand(): Boolean
1632
+ /** Routes an external controller's tap on a queue row (Android Auto's
1633
+ * "seek to this item") through the lib's `skipToIndex` path so the
1634
+ * canonical current index + track-change reason land. Returns true
1635
+ * when handled so the default per-item seek is suppressed; returns
1636
+ * false for an out-of-range index so the default routing no-ops it. */
1637
+ fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean
1665
1638
 
1666
1639
  /**
1667
1640
  * Returns the lib's current authoritative skip capability —
@@ -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()
@@ -368,6 +360,14 @@ internal class PlaybackServiceCallback(
368
360
  return dispatch.handle(intent, RemoteCommandDeduper.Channel.SESSION_KEY)
369
361
  }
370
362
 
363
+ /**
364
+ * Cold-start playback resumption: a hardware / Bluetooth media button that
365
+ * wakes the service while nothing is loaded. Returns an empty queue by
366
+ * design — the lib does not reconstruct a queue here; a consumer that wants
367
+ * resume-from-cold restores its own queue on launch via `setQueue`. The
368
+ * override exists (rather than being omitted) because Media3 requires a
369
+ * declared `MediaButtonReceiver` to pair with an `onPlaybackResumption`.
370
+ */
371
371
  override fun onPlaybackResumption(
372
372
  mediaSession: MediaSession,
373
373
  controller: MediaSession.ControllerInfo,
@@ -89,6 +89,15 @@ internal class SessionCommandForwardingPlayer(
89
89
  Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
90
90
  if (commandHandler()?.onSkipToPreviousCommand() == true) Futures.immediateVoidFuture()
91
91
  else super.handleSeek(mediaItemIndex, positionMs, seekCommand)
92
+ // Android Auto's queue-row tap. A raw per-item seek would move the
93
+ // player without updating the lib's canonical current index — the
94
+ // SEEK transition handler only syncs the index for player-originated
95
+ // transitions, not controller seeks. Route through the skip path so
96
+ // the index + track-change reason land; an index the lib declines
97
+ // (out of range) falls through to the default seek.
98
+ Player.COMMAND_SEEK_TO_MEDIA_ITEM ->
99
+ if (commandHandler()?.onSeekToMediaItemCommand(mediaItemIndex) == true) Futures.immediateVoidFuture()
100
+ else super.handleSeek(mediaItemIndex, positionMs, seekCommand)
92
101
  // Interval-skip mode: recompute the target with the runtime jump interval
93
102
  // rather than the wrapped player's build-time seek increment, so the value
94
103
  // is adjustable at runtime.
@@ -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
@@ -991,6 +980,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
991
980
  return true
992
981
  }
993
982
 
983
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean {
984
+ // Decline an out-of-range index so the default per-item seek
985
+ // no-ops it (Media3 passes C.INDEX_UNSET when the seek implies
986
+ // no move).
987
+ if (mediaItemIndex < 0 || mediaItemIndex >= tracks.size) return false
988
+ pendingStateChangeReason = StateChangeReason.SYSTEM
989
+ // Same path as the public skipToIndex: jump the receiver while
990
+ // casting, else the local engine. Both write currentTrackIndex +
991
+ // the USER_SKIP_TO_INDEX reason that a raw controller seek skips.
992
+ // The tapped track starts at position 0 — the controller's
993
+ // positionMs is intentionally not threaded through, matching the
994
+ // public skipToIndex "play this track" contract.
995
+ if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(mediaItemIndex)) {
996
+ return true
997
+ }
998
+ skipToIndexInternal(mediaItemIndex)
999
+ return true
1000
+ }
1001
+
994
1002
  override fun onMediaPlay() {
995
1003
  pendingStateChangeReason = StateChangeReason.SYSTEM
996
1004
  serviceBinder?.engine?.play()
@@ -2222,21 +2230,15 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2222
2230
  val current = cachedSkipCapability
2223
2231
  val capChanged =
2224
2232
  newPrev != current.canSkipPrevious || newNext != current.canSkipNext
2225
- val hasMediaItems = count > 0
2226
- val emptyStateChanged = hasMediaItems != lastHasMediaItemsReported
2227
- if (!capChanged && !emptyStateChanged) return
2228
- lastHasMediaItemsReported = hasMediaItems
2233
+ if (!capChanged) return
2229
2234
  val snapshot = SkipCapability(
2230
2235
  canSkipPrevious = newPrev, canSkipNext = newNext)
2231
2236
  cachedSkipCapability = snapshot
2232
- if (capChanged) skipCapabilityListeners.forEach { it(snapshot) }
2233
- // Push the new capability into MediaSession.setAvailableCommands
2234
- // for every connected controller so external surfaces (lock-
2235
- // screen, Bluetooth AVRCP, Auto, Wear) grey out skip buttons
2236
- // that the queue boundary makes invalid, and so the PLAY-bit
2237
- // strip from `playerCommandsFor(_, hasMediaItems=false)` reaches
2238
- // gearhead the moment the queue empties (and re-appears the
2239
- // 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.
2240
2242
  serviceBinder?.refreshAvailableCommands()
2241
2243
  }
2242
2244
 
@@ -3441,6 +3443,33 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
3441
3443
 
3442
3444
  override fun onActiveItemFormatChanged(engine: PlaybackEngine) {
3443
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)
3444
3473
  }
3445
3474
 
3446
3475
  override fun onError(engine: PlaybackEngine, exception: PlaybackException) {
@@ -11,9 +11,6 @@
11
11
  transport button. Shown by TalkBack and by pre-Media3 controllers
12
12
  that read the platform PlaybackStateCompat custom-action name. -->
13
13
  <string name="rnqp_action_previous">Previous</string>
14
- <!-- Accessibility / legacy-controller label for the play-pause
15
- transport button. -->
16
- <string name="rnqp_action_play_pause">Play or pause</string>
17
14
  <!-- Accessibility / legacy-controller label for the next-track
18
15
  transport button. -->
19
16
  <string name="rnqp_action_next">Next</string>
@@ -252,6 +252,7 @@ class AvrcpMetadataTest {
252
252
  override fun onTransportCommand() {}
253
253
  override fun onSkipToNextCommand(): Boolean = false
254
254
  override fun onSkipToPreviousCommand(): Boolean = false
255
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
255
256
  override fun getCachedSkipCapability(): SkipCapability = capability
256
257
  override fun onMediaPlay() {}
257
258
  override fun onMediaPause() {}
@@ -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 {
@@ -179,6 +179,7 @@ class MediaButtonDispatchTest {
179
179
  override fun onTransportCommand() { transportCalls++ }
180
180
  override fun onSkipToNextCommand(): Boolean { skipNextCmdCalls++; return true }
181
181
  override fun onSkipToPreviousCommand(): Boolean { skipPreviousCmdCalls++; return true }
182
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
182
183
  override fun getCachedSkipCapability(): SkipCapability =
183
184
  SkipCapability(canSkipPrevious = true, canSkipNext = true)
184
185
  override fun onMediaPlay() { playCalls++ }
@@ -0,0 +1,99 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.content.Intent
4
+ import androidx.media3.common.Player
5
+ import androidx.media3.common.util.UnstableApi
6
+ import androidx.media3.session.CommandButton
7
+ import androidx.media3.session.MediaSession
8
+ import androidx.test.core.app.ApplicationProvider
9
+ import org.junit.After
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertTrue
12
+ import org.junit.Before
13
+ import org.junit.Test
14
+ import org.junit.runner.RunWith
15
+ import org.robolectric.Robolectric
16
+ import org.robolectric.RobolectricTestRunner
17
+ import org.robolectric.android.controller.ServiceController
18
+ import org.robolectric.annotation.Config
19
+
20
+ /**
21
+ * Robolectric coverage for the media button preferences the notification
22
+ * compact view + Android Auto control row consume, produced by
23
+ * [PlaybackService.buildMediaButtonPreferences] and read back off the
24
+ * session's public `mediaButtonPreferences`.
25
+ *
26
+ * The lib supplies only the back / forward buttons. Play/pause belongs to
27
+ * Media3's central slot: `DefaultMediaNotificationProvider` synthesizes a
28
+ * state-aware play/pause from `COMMAND_PLAY_PAUSE`, and Android Auto fills
29
+ * the empty central slot the same way — so a lib-supplied `SLOT_CENTRAL`
30
+ * button is dropped on the notification and forces a static, non-toggling
31
+ * icon on Android Auto.
32
+ */
33
+ @RunWith(RobolectricTestRunner::class)
34
+ @Config(sdk = [34])
35
+ @UnstableApi
36
+ class MediaButtonPreferencesTest {
37
+
38
+ private lateinit var controller: ServiceController<PlaybackService>
39
+ private lateinit var service: PlaybackService
40
+ private lateinit var session: MediaSession
41
+
42
+ @Before
43
+ fun setUp() {
44
+ controller = Robolectric.buildService(PlaybackService::class.java).create()
45
+ service = controller.get()
46
+ val intent = Intent(
47
+ ApplicationProvider.getApplicationContext<android.content.Context>(),
48
+ PlaybackService::class.java,
49
+ ).setAction(PlaybackService.ACTION_LOCAL_BIND)
50
+ val binder = service.onBind(intent) as PlaybackService.LocalBinder
51
+ session = binder.session
52
+ }
53
+
54
+ @After
55
+ fun tearDown() {
56
+ controller.destroy()
57
+ }
58
+
59
+ @Test
60
+ fun `track mode supplies previous plus next and leaves play-pause to Media3`() {
61
+ service.applyRemoteControls(RemoteSkipMode.TRACK, 15_000L, 15_000L)
62
+ val prefs = session.mediaButtonPreferences
63
+ assertNoLibPlayPause(prefs)
64
+ assertTrue(
65
+ "previous occupies the back slot",
66
+ prefs.any { it.slots.contains(CommandButton.SLOT_BACK) },
67
+ )
68
+ assertTrue(
69
+ "next occupies the forward slot",
70
+ prefs.any { it.slots.contains(CommandButton.SLOT_FORWARD) },
71
+ )
72
+ }
73
+
74
+ @Test
75
+ fun `interval mode supplies skip-back plus skip-forward and leaves play-pause to Media3`() {
76
+ service.applyRemoteControls(RemoteSkipMode.INTERVAL, 15_000L, 10_000L)
77
+ val prefs = session.mediaButtonPreferences
78
+ assertNoLibPlayPause(prefs)
79
+ assertTrue(
80
+ "skip-back occupies the back slot",
81
+ prefs.any { it.slots.contains(CommandButton.SLOT_BACK) },
82
+ )
83
+ assertTrue(
84
+ "skip-forward occupies the forward slot",
85
+ prefs.any { it.slots.contains(CommandButton.SLOT_FORWARD) },
86
+ )
87
+ }
88
+
89
+ private fun assertNoLibPlayPause(prefs: List<CommandButton>) {
90
+ assertFalse(
91
+ "play/pause is Media3's central-slot button, never supplied by the lib",
92
+ prefs.any { it.playerCommand == Player.COMMAND_PLAY_PAUSE },
93
+ )
94
+ assertFalse(
95
+ "the lib never claims SLOT_CENTRAL — Media3 owns it",
96
+ prefs.any { it.slots.contains(CommandButton.SLOT_CENTRAL) },
97
+ )
98
+ }
99
+ }
@@ -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
  }