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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +6 -42
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +7 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +72 -23
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +15 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/ios/AVQueueBuilder.swift +29 -0
- package/ios/QueueState.swift +14 -2
- package/ios/Tests/AVQueueBuilderTests.swift +74 -0
- package/ios/Tests/QueueStateTests.swift +50 -0
- package/ios/TrackPlayer.swift +121 -14
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/useQueue.js +57 -0
- package/lib/module/hooks/useQueue.js.map +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +7 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/index.d.ts +2 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +20 -0
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +5 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
- package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
- package/package.json +1 -1
- package/src/TrackPlayer.nitro.ts +33 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useQueue.ts +56 -0
- package/src/index.ts +1 -0
- 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
|
|
312
|
+
this@PlaybackService.playerCommandsFor(capability)
|
|
314
313
|
|
|
315
314
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
316
315
|
// Cold-wake the JS bridge so the consumer's
|
|
@@ -471,15 +470,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
471
470
|
internal fun refreshAvailableCommands() {
|
|
472
471
|
val handler = commandHandler ?: return
|
|
473
472
|
val session = mediaSession ?: return
|
|
474
|
-
// The cap is global — same Player.Commands for every controller
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
// dashboard tap routes to Library instead of an empty Now Playing.
|
|
478
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
479
|
-
val commands = playerCommandsFor(
|
|
480
|
-
handler.getCachedSkipCapability(),
|
|
481
|
-
hasMediaItems
|
|
482
|
-
)
|
|
473
|
+
// The cap is global — same Player.Commands for every controller;
|
|
474
|
+
// only the four skip commands flip with the queue boundary.
|
|
475
|
+
val commands = playerCommandsFor(handler.getCachedSkipCapability())
|
|
483
476
|
for (controller in session.connectedControllers) {
|
|
484
477
|
session.setAvailableCommands(
|
|
485
478
|
controller,
|
|
@@ -1603,38 +1596,9 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
1603
1596
|
* from `DEFAULT_PLAYER_COMMANDS`.
|
|
1604
1597
|
*/
|
|
1605
1598
|
internal fun playerCommandsFor(
|
|
1606
|
-
capability: SkipCapability
|
|
1607
|
-
hasMediaItems: Boolean
|
|
1599
|
+
capability: SkipCapability
|
|
1608
1600
|
): Player.Commands {
|
|
1609
1601
|
val builder = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
|
1610
|
-
if (!hasMediaItems) {
|
|
1611
|
-
// Empty queue → strip every command that contributes to the
|
|
1612
|
-
// legacy `PlaybackStateCompat` action mask's PLAY-related bits.
|
|
1613
|
-
// Android Auto's dashboard-tap launch heuristic reads that mask:
|
|
1614
|
-
// STATE_NONE alone is not enough — the action mask must also
|
|
1615
|
-
// carry no ACTION_PLAY / ACTION_PLAY_PAUSE / ACTION_PREPARE.
|
|
1616
|
-
// Once both are zero, AA lands on the Library/Browse surface
|
|
1617
|
-
// instead of "Now Playing — To play something, tap the icon at
|
|
1618
|
-
// the top left."
|
|
1619
|
-
//
|
|
1620
|
-
// This rides Media3 1.10.0's internal Player.Commands →
|
|
1621
|
-
// PlaybackStateCompat action-mask translation, which is not a public
|
|
1622
|
-
// contract — re-validate on a Media3 upgrade. Unit tests cover the
|
|
1623
|
-
// command-strip input; the resulting action mask is verified on the
|
|
1624
|
-
// Android Auto lane, since the legacy bridge only populates for a
|
|
1625
|
-
// connected controller that can't be driven under Robolectric.
|
|
1626
|
-
builder.remove(Player.COMMAND_PLAY_PAUSE)
|
|
1627
|
-
builder.remove(Player.COMMAND_PREPARE)
|
|
1628
|
-
builder.remove(Player.COMMAND_STOP)
|
|
1629
|
-
builder.remove(Player.COMMAND_SEEK_BACK)
|
|
1630
|
-
builder.remove(Player.COMMAND_SEEK_FORWARD)
|
|
1631
|
-
builder.remove(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
|
|
1632
|
-
builder.remove(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
|
|
1633
|
-
builder.remove(Player.COMMAND_SEEK_TO_MEDIA_ITEM)
|
|
1634
|
-
builder.remove(Player.COMMAND_SET_SHUFFLE_MODE)
|
|
1635
|
-
builder.remove(Player.COMMAND_SET_REPEAT_MODE)
|
|
1636
|
-
builder.remove(Player.COMMAND_SET_SPEED_AND_PITCH)
|
|
1637
|
-
}
|
|
1638
1602
|
if (!capability.canSkipNext) {
|
|
1639
1603
|
builder.remove(Player.COMMAND_SEEK_TO_NEXT)
|
|
1640
1604
|
builder.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
|
@@ -56,17 +56,13 @@ internal class PlaybackServiceCallback(
|
|
|
56
56
|
fun mediaButtonDispatch(): MediaButtonDispatch?
|
|
57
57
|
|
|
58
58
|
/** Translate the lib's queue state into the [Player.Commands] set
|
|
59
|
-
* controllers should see.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* dashboard tap route to the Library/Browse surface instead of
|
|
65
|
-
* the empty "Now Playing — To play something" fallback when the
|
|
66
|
-
* queue is empty. */
|
|
59
|
+
* controllers should see. Only the skip pair greys out (the
|
|
60
|
+
* repeat-mode + queue-position arithmetic disallows it); every
|
|
61
|
+
* other command — play/pause, seek, etc. — is preserved so the
|
|
62
|
+
* API 33+ System UI derives the play/pause button + the draggable
|
|
63
|
+
* seek scrubber from the session's transport commands. */
|
|
67
64
|
fun playerCommandsFor(
|
|
68
|
-
capability: SkipCapability
|
|
69
|
-
hasMediaItems: Boolean
|
|
65
|
+
capability: SkipCapability
|
|
70
66
|
): Player.Commands
|
|
71
67
|
|
|
72
68
|
/** Invoked when a controller from a known headless-eligible
|
|
@@ -317,12 +313,8 @@ internal class PlaybackServiceCallback(
|
|
|
317
313
|
)
|
|
318
314
|
val handler = environment.commandHandler()
|
|
319
315
|
if (handler != null) {
|
|
320
|
-
val hasMediaItems = session.player.mediaItemCount > 0
|
|
321
316
|
builder.setAvailablePlayerCommands(
|
|
322
|
-
environment.playerCommandsFor(
|
|
323
|
-
handler.getCachedSkipCapability(),
|
|
324
|
-
hasMediaItems
|
|
325
|
-
)
|
|
317
|
+
environment.playerCommandsFor(handler.getCachedSkipCapability())
|
|
326
318
|
)
|
|
327
319
|
}
|
|
328
320
|
return builder.build()
|
|
@@ -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
|
-
|
|
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
|
-
|
|
2252
|
-
// Push the new capability into MediaSession.setAvailableCommands
|
|
2253
|
-
//
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
// strip from `playerCommandsFor(_, hasMediaItems=false)` reaches
|
|
2257
|
-
// gearhead the moment the queue empties (and re-appears the
|
|
2258
|
-
// moment a new queue is set).
|
|
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)
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt
CHANGED
|
@@ -278,10 +278,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
278
278
|
override fun commandHandler() = null
|
|
279
279
|
override fun mediaButtonDispatch() = null
|
|
280
280
|
override fun playerCommandsFor(
|
|
281
|
-
capability: SkipCapability
|
|
282
|
-
hasMediaItems: Boolean
|
|
281
|
+
capability: SkipCapability
|
|
283
282
|
): Player.Commands =
|
|
284
|
-
service.playerCommandsFor(capability
|
|
283
|
+
service.playerCommandsFor(capability)
|
|
285
284
|
override fun onBrowseRequestForChildren(
|
|
286
285
|
parentId: String
|
|
287
286
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -309,10 +308,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
309
308
|
override fun commandHandler() = null
|
|
310
309
|
override fun mediaButtonDispatch() = null
|
|
311
310
|
override fun playerCommandsFor(
|
|
312
|
-
capability: SkipCapability
|
|
313
|
-
hasMediaItems: Boolean
|
|
311
|
+
capability: SkipCapability
|
|
314
312
|
): Player.Commands =
|
|
315
|
-
service.playerCommandsFor(capability
|
|
313
|
+
service.playerCommandsFor(capability)
|
|
316
314
|
override fun onBrowseRequestForChildren(
|
|
317
315
|
parentId: String
|
|
318
316
|
): ListenableFuture<List<BrowseItem>> = Futures.immediateFuture(resolved)
|
|
@@ -438,10 +436,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
438
436
|
override fun commandHandler() = null
|
|
439
437
|
override fun mediaButtonDispatch() = null
|
|
440
438
|
override fun playerCommandsFor(
|
|
441
|
-
capability: SkipCapability
|
|
442
|
-
hasMediaItems: Boolean
|
|
439
|
+
capability: SkipCapability
|
|
443
440
|
): Player.Commands =
|
|
444
|
-
service.playerCommandsFor(capability
|
|
441
|
+
service.playerCommandsFor(capability)
|
|
445
442
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
446
443
|
called = packageName
|
|
447
444
|
}
|
|
@@ -462,10 +459,9 @@ class PlaybackServiceCallbackBrowseTest {
|
|
|
462
459
|
override fun commandHandler() = null
|
|
463
460
|
override fun mediaButtonDispatch() = null
|
|
464
461
|
override fun playerCommandsFor(
|
|
465
|
-
capability: SkipCapability
|
|
466
|
-
hasMediaItems: Boolean
|
|
462
|
+
capability: SkipCapability
|
|
467
463
|
): Player.Commands =
|
|
468
|
-
service.playerCommandsFor(capability
|
|
464
|
+
service.playerCommandsFor(capability)
|
|
469
465
|
override fun onHeadlessControllerConnect(packageName: String) {
|
|
470
466
|
called = packageName
|
|
471
467
|
}
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt
CHANGED
|
@@ -68,10 +68,9 @@ class PlaybackServiceCallbackSearchTest {
|
|
|
68
68
|
override fun commandHandler() = null
|
|
69
69
|
override fun mediaButtonDispatch() = null
|
|
70
70
|
override fun playerCommandsFor(
|
|
71
|
-
capability: SkipCapability
|
|
72
|
-
hasMediaItems: Boolean
|
|
71
|
+
capability: SkipCapability
|
|
73
72
|
): Player.Commands =
|
|
74
|
-
service.playerCommandsFor(capability
|
|
73
|
+
service.playerCommandsFor(capability)
|
|
75
74
|
override fun onSearchQueryReceived(query: String) {
|
|
76
75
|
received.add(query)
|
|
77
76
|
}
|
|
@@ -227,8 +227,7 @@ class PlaybackServiceCallbackTest {
|
|
|
227
227
|
@Test
|
|
228
228
|
fun `playerCommandsFor strips both skips when capability disallows both`() {
|
|
229
229
|
val commands = service.playerCommandsFor(
|
|
230
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
231
|
-
hasMediaItems = true
|
|
230
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
232
231
|
)
|
|
233
232
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
234
233
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -243,23 +242,21 @@ class PlaybackServiceCallbackTest {
|
|
|
243
242
|
}
|
|
244
243
|
|
|
245
244
|
@Test
|
|
246
|
-
fun `playerCommandsFor
|
|
247
|
-
//
|
|
248
|
-
//
|
|
245
|
+
fun `playerCommandsFor keeps transport commands when queue is empty`() {
|
|
246
|
+
// Transport commands stay available so the API 33+ System UI keeps the
|
|
247
|
+
// play/pause button + the draggable seek scrubber; only the skip pair
|
|
248
|
+
// greys out with the queue boundary.
|
|
249
249
|
val commands = service.playerCommandsFor(
|
|
250
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
251
|
-
hasMediaItems = false
|
|
250
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
252
251
|
)
|
|
253
|
-
|
|
254
|
-
"
|
|
252
|
+
assertTrue(
|
|
253
|
+
"play/pause is retained for the System UI play/pause button",
|
|
255
254
|
commands.contains(Player.COMMAND_PLAY_PAUSE)
|
|
256
255
|
)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assertFalse(commands.contains(Player.COMMAND_SET_REPEAT_MODE))
|
|
262
|
-
assertFalse(commands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
|
|
256
|
+
assertTrue(commands.contains(Player.COMMAND_PREPARE))
|
|
257
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
|
|
258
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
|
|
259
|
+
assertTrue(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
|
|
263
260
|
}
|
|
264
261
|
|
|
265
262
|
@Test
|
|
@@ -342,7 +339,7 @@ class PlaybackServiceCallbackTest {
|
|
|
342
339
|
// The handler's value is what `PlaybackCallback.onConnect` reads,
|
|
343
340
|
// so a fresh `onConnect` against any session sharing this handler
|
|
344
341
|
// sees the post-flip command set.
|
|
345
|
-
val playerCommands = service.playerCommandsFor(mid
|
|
342
|
+
val playerCommands = service.playerCommandsFor(mid)
|
|
346
343
|
assertTrue(playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
347
344
|
assertFalse(playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
348
345
|
} finally {
|
|
@@ -876,10 +873,9 @@ class PlaybackServiceCallbackTest {
|
|
|
876
873
|
override fun mediaButtonDispatch(): MediaButtonDispatch? = null
|
|
877
874
|
|
|
878
875
|
override fun playerCommandsFor(
|
|
879
|
-
capability: SkipCapability
|
|
880
|
-
hasMediaItems: Boolean
|
|
876
|
+
capability: SkipCapability
|
|
881
877
|
): Player.Commands =
|
|
882
|
-
service.playerCommandsFor(capability
|
|
878
|
+
service.playerCommandsFor(capability)
|
|
883
879
|
|
|
884
880
|
override fun onPlayFromSearchRequest(
|
|
885
881
|
request: MediaSearchRequest
|