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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
- package/android/src/main/res/values/strings.xml +0 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/ios/CarPlayCoordinator.swift +10 -0
- package/ios/CarPlayQueueBuilder.swift +82 -0
- package/ios/CarPlayQueueProvider.swift +73 -0
- package/ios/CarPlaySceneDelegate.swift +79 -7
- package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
- package/ios/Tests/FFTProcessorTests.swift +9 -7
- package/ios/TrackPlayer.swift +47 -0
- package/package.json +1 -1
|
@@ -243,6 +243,7 @@ internal class AirPlayEngine(
|
|
|
243
243
|
override val bufferedPositionMs: Long get() = exoPlayer.bufferedPosition
|
|
244
244
|
override val isPlaying: Boolean get() = exoPlayer.isPlaying
|
|
245
245
|
override val currentMediaItem: MediaItem? get() = exoPlayer.currentMediaItem
|
|
246
|
+
override val isCrossfadeActive: Boolean get() = false
|
|
246
247
|
|
|
247
248
|
override val allMediaItems: List<MediaItem>
|
|
248
249
|
get() {
|
|
@@ -753,6 +753,9 @@ internal class CrossfadeEngine(
|
|
|
753
753
|
override val currentMediaItem: MediaItem?
|
|
754
754
|
get() = activePlayer.currentMediaItem
|
|
755
755
|
|
|
756
|
+
override val isCrossfadeActive: Boolean
|
|
757
|
+
get() = fadeJob?.isActive == true
|
|
758
|
+
|
|
756
759
|
override val allMediaItems: List<MediaItem>
|
|
757
760
|
get() {
|
|
758
761
|
val count = leadingPlayer.mediaItemCount
|
|
@@ -373,6 +373,9 @@ internal class GaplessEngine(
|
|
|
373
373
|
override val currentMediaItem: MediaItem?
|
|
374
374
|
get() = exoPlayer.currentMediaItem
|
|
375
375
|
|
|
376
|
+
override val isCrossfadeActive: Boolean
|
|
377
|
+
get() = false
|
|
378
|
+
|
|
376
379
|
override val allMediaItems: List<MediaItem>
|
|
377
380
|
get() {
|
|
378
381
|
val count = exoPlayer.mediaItemCount
|
|
@@ -113,6 +113,19 @@ object MediaItemBuilder {
|
|
|
113
113
|
return builder.build()
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Duration (ms) to write onto the current item's metadata for the API 33+
|
|
118
|
+
* System UI seekbar, or `null` to leave it untouched. A positive
|
|
119
|
+
* consumer-supplied duration wins; otherwise the decoded player duration is
|
|
120
|
+
* used; a non-positive player duration (unknown / live / `C.TIME_UNSET`,
|
|
121
|
+
* which is negative) yields `null`.
|
|
122
|
+
*/
|
|
123
|
+
internal fun durationToBackfill(existingDurationMs: Long?, playerDurationMs: Long): Long? {
|
|
124
|
+
if (existingDurationMs != null && existingDurationMs > 0L) return null
|
|
125
|
+
if (playerDurationMs <= 0L) return null
|
|
126
|
+
return playerDurationMs
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
/**
|
|
117
130
|
* URI for the bundled placeholder artwork in `res/raw/`. Cached
|
|
118
131
|
* per-process; the integer resource ID is package-stable at runtime
|
|
@@ -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
|
|
312
|
+
this@PlaybackService.playerCommandsFor(capability)
|
|
314
313
|
|
|
315
314
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
316
315
|
// Cold-wake the JS bridge so the consumer's
|
|
@@ -471,15 +470,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
471
470
|
internal fun refreshAvailableCommands() {
|
|
472
471
|
val handler = commandHandler ?: return
|
|
473
472
|
val session = mediaSession ?: return
|
|
474
|
-
// The cap is global — same Player.Commands for every controller
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
// dashboard tap routes to Library instead of an empty Now Playing.
|
|
478
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
479
|
-
val commands = playerCommandsFor(
|
|
480
|
-
handler.getCachedSkipCapability(),
|
|
481
|
-
hasMediaItems
|
|
482
|
-
)
|
|
473
|
+
// The cap is global — same Player.Commands for every controller;
|
|
474
|
+
// only the four skip commands flip with the queue boundary.
|
|
475
|
+
val commands = playerCommandsFor(handler.getCachedSkipCapability())
|
|
483
476
|
for (controller in session.connectedControllers) {
|
|
484
477
|
session.setAvailableCommands(
|
|
485
478
|
controller,
|
|
@@ -788,20 +781,18 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
788
781
|
)
|
|
789
782
|
|
|
790
783
|
/**
|
|
791
|
-
* The
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* `DefaultMediaNotificationProvider`
|
|
797
|
-
*
|
|
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
|
|
1650
|
-
* when the implementation has handled the
|
|
1651
|
-
* default routing is suppressed; the transport hook fires for
|
|
1652
|
-
* pause / seek / setPlaybackSpeed and is purely a
|
|
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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* dashboard tap route to the Library/Browse surface instead of
|
|
65
|
-
* the empty "Now Playing — To play something" fallback when the
|
|
66
|
-
* queue is empty. */
|
|
59
|
+
* controllers should see. Only the skip pair greys out (the
|
|
60
|
+
* repeat-mode + queue-position arithmetic disallows it); every
|
|
61
|
+
* other command — play/pause, seek, etc. — is preserved so the
|
|
62
|
+
* API 33+ System UI derives the play/pause button + the draggable
|
|
63
|
+
* seek scrubber from the session's transport commands. */
|
|
67
64
|
fun playerCommandsFor(
|
|
68
|
-
capability: SkipCapability
|
|
69
|
-
hasMediaItems: Boolean
|
|
65
|
+
capability: SkipCapability
|
|
70
66
|
): Player.Commands
|
|
71
67
|
|
|
72
68
|
/** Invoked when a controller from a known headless-eligible
|
|
@@ -317,12 +313,8 @@ internal class PlaybackServiceCallback(
|
|
|
317
313
|
)
|
|
318
314
|
val handler = environment.commandHandler()
|
|
319
315
|
if (handler != null) {
|
|
320
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
321
316
|
builder.setAvailablePlayerCommands(
|
|
322
|
-
environment.playerCommandsFor(
|
|
323
|
-
handler.getCachedSkipCapability(),
|
|
324
|
-
hasMediaItems
|
|
325
|
-
)
|
|
317
|
+
environment.playerCommandsFor(handler.getCachedSkipCapability())
|
|
326
318
|
)
|
|
327
319
|
}
|
|
328
320
|
return builder.build()
|
|
@@ -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,
|
package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2233
|
-
// Push the new capability into MediaSession.setAvailableCommands
|
|
2234
|
-
//
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
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)
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt
CHANGED
|
@@ -278,10 +278,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
278
278
|
override fun commandHandler() = null
|
|
279
279
|
override fun mediaButtonDispatch() = null
|
|
280
280
|
override fun playerCommandsFor(
|
|
281
|
-
capability: SkipCapability
|
|
282
|
-
hasMediaItems: Boolean
|
|
281
|
+
capability: SkipCapability
|
|
283
282
|
): Player.Commands =
|
|
284
|
-
service.playerCommandsFor(capability
|
|
283
|
+
service.playerCommandsFor(capability)
|
|
285
284
|
override fun onBrowseRequestForChildren(
|
|
286
285
|
parentId: String
|
|
287
286
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -309,10 +308,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
309
308
|
override fun commandHandler() = null
|
|
310
309
|
override fun mediaButtonDispatch() = null
|
|
311
310
|
override fun playerCommandsFor(
|
|
312
|
-
capability: SkipCapability
|
|
313
|
-
hasMediaItems: Boolean
|
|
311
|
+
capability: SkipCapability
|
|
314
312
|
): Player.Commands =
|
|
315
|
-
service.playerCommandsFor(capability
|
|
313
|
+
service.playerCommandsFor(capability)
|
|
316
314
|
override fun onBrowseRequestForChildren(
|
|
317
315
|
parentId: String
|
|
318
316
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -438,10 +436,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
438
436
|
override fun commandHandler() = null
|
|
439
437
|
override fun mediaButtonDispatch() = null
|
|
440
438
|
override fun playerCommandsFor(
|
|
441
|
-
capability: SkipCapability
|
|
442
|
-
hasMediaItems: Boolean
|
|
439
|
+
capability: SkipCapability
|
|
443
440
|
): Player.Commands =
|
|
444
|
-
service.playerCommandsFor(capability
|
|
441
|
+
service.playerCommandsFor(capability)
|
|
445
442
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
446
443
|
called = packageName
|
|
447
444
|
}
|
|
@@ -462,10 +459,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
462
459
|
override fun commandHandler() = null
|
|
463
460
|
override fun mediaButtonDispatch() = null
|
|
464
461
|
override fun playerCommandsFor(
|
|
465
|
-
capability: SkipCapability
|
|
466
|
-
hasMediaItems: Boolean
|
|
462
|
+
capability: SkipCapability
|
|
467
463
|
): Player.Commands =
|
|
468
|
-
service.playerCommandsFor(capability
|
|
464
|
+
service.playerCommandsFor(capability)
|
|
469
465
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
470
466
|
called = packageName
|
|
471
467
|
}
|