react-native-queue-player 1.0.4 → 1.0.6
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/NotificationProvider.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +28 -19
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +19 -0
- 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/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/PlaybackServiceCallbackTest.kt +72 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
- 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
|
@@ -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
|
|
@@ -788,20 +788,18 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
788
788
|
)
|
|
789
789
|
|
|
790
790
|
/**
|
|
791
|
-
* The
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* `DefaultMediaNotificationProvider`
|
|
797
|
-
*
|
|
791
|
+
* The back + forward buttons for the notification compact view and the
|
|
792
|
+
* Android Auto control row. Play/pause is intentionally NOT supplied:
|
|
793
|
+
* Media3 owns the central slot (`CommandButton.SLOT_CENTRAL`) and
|
|
794
|
+
* synthesizes a state-aware play/pause from `COMMAND_PLAY_PAUSE`. A
|
|
795
|
+
* caller-supplied central button is dropped on the notification path
|
|
796
|
+
* (`DefaultMediaNotificationProvider` builds its own play/pause) and on
|
|
797
|
+
* Android Auto forces a static, non-toggling icon — so supplying only
|
|
798
|
+
* back/forward lets Media3 render the toggling play/pause on both
|
|
799
|
+
* surfaces. The expanded view adds the scrub progress bar automatically
|
|
800
|
+
* when the session reports a known duration.
|
|
798
801
|
*/
|
|
799
802
|
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
803
|
// In `interval` mode the back/forward slots jump within the current track
|
|
806
804
|
// (COMMAND_SEEK_BACK / COMMAND_SEEK_FORWARD, honored by
|
|
807
805
|
// [SessionCommandForwardingPlayer] with the runtime interval); in `track`
|
|
@@ -813,7 +811,6 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
813
811
|
.setSlots(CommandButton.SLOT_BACK)
|
|
814
812
|
.setDisplayName(getString(R.string.rnqp_action_jump_backward))
|
|
815
813
|
.build(),
|
|
816
|
-
playPause,
|
|
817
814
|
CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD)
|
|
818
815
|
.setPlayerCommand(Player.COMMAND_SEEK_FORWARD)
|
|
819
816
|
.setSlots(CommandButton.SLOT_FORWARD)
|
|
@@ -826,7 +823,6 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
826
823
|
.setSlots(CommandButton.SLOT_BACK)
|
|
827
824
|
.setDisplayName(getString(R.string.rnqp_action_previous))
|
|
828
825
|
.build(),
|
|
829
|
-
playPause,
|
|
830
826
|
CommandButton.Builder(CommandButton.ICON_NEXT)
|
|
831
827
|
.setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT)
|
|
832
828
|
.setSlots(CommandButton.SLOT_FORWARD)
|
|
@@ -1620,6 +1616,13 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
1620
1616
|
// Once both are zero, AA lands on the Library/Browse surface
|
|
1621
1617
|
// instead of "Now Playing — To play something, tap the icon at
|
|
1622
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.
|
|
1623
1626
|
builder.remove(Player.COMMAND_PLAY_PAUSE)
|
|
1624
1627
|
builder.remove(Player.COMMAND_PREPARE)
|
|
1625
1628
|
builder.remove(Player.COMMAND_STOP)
|
|
@@ -1646,11 +1649,11 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
1646
1649
|
/**
|
|
1647
1650
|
* Hook the [PlaybackCallback] consults before letting Media3 route
|
|
1648
1651
|
* 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).
|
|
1652
|
+
* the service main looper. Skip-next / skip-previous / seek-to-queue-item
|
|
1653
|
+
* return true when the implementation has handled the command itself,
|
|
1654
|
+
* so the default routing is suppressed; the transport hook fires for
|
|
1655
|
+
* play / pause / in-item seek / setPlaybackSpeed and is purely a
|
|
1656
|
+
* side-channel notification (no return value).
|
|
1654
1657
|
*/
|
|
1655
1658
|
internal interface MediaSessionCommandHandler {
|
|
1656
1659
|
/** Stamps `system` on the state-change channel for the upcoming
|
|
@@ -1662,6 +1665,12 @@ class PlaybackService : HeadlessJsMediaService() {
|
|
|
1662
1665
|
/** Returns true if the lib intercepted the skip and the default
|
|
1663
1666
|
* routing should be suppressed. */
|
|
1664
1667
|
fun onSkipToPreviousCommand(): Boolean
|
|
1668
|
+
/** Routes an external controller's tap on a queue row (Android Auto's
|
|
1669
|
+
* "seek to this item") through the lib's `skipToIndex` path so the
|
|
1670
|
+
* canonical current index + track-change reason land. Returns true
|
|
1671
|
+
* when handled so the default per-item seek is suppressed; returns
|
|
1672
|
+
* false for an out-of-range index so the default routing no-ops it. */
|
|
1673
|
+
fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean
|
|
1665
1674
|
|
|
1666
1675
|
/**
|
|
1667
1676
|
* Returns the lib's current authoritative skip capability —
|
|
@@ -368,6 +368,14 @@ internal class PlaybackServiceCallback(
|
|
|
368
368
|
return dispatch.handle(intent, RemoteCommandDeduper.Channel.SESSION_KEY)
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Cold-start playback resumption: a hardware / Bluetooth media button that
|
|
373
|
+
* wakes the service while nothing is loaded. Returns an empty queue by
|
|
374
|
+
* design — the lib does not reconstruct a queue here; a consumer that wants
|
|
375
|
+
* resume-from-cold restores its own queue on launch via `setQueue`. The
|
|
376
|
+
* override exists (rather than being omitted) because Media3 requires a
|
|
377
|
+
* declared `MediaButtonReceiver` to pair with an `onPlaybackResumption`.
|
|
378
|
+
*/
|
|
371
379
|
override fun onPlaybackResumption(
|
|
372
380
|
mediaSession: MediaSession,
|
|
373
381
|
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.
|
|
@@ -991,6 +991,25 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
991
991
|
return true
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
+
override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean {
|
|
995
|
+
// Decline an out-of-range index so the default per-item seek
|
|
996
|
+
// no-ops it (Media3 passes C.INDEX_UNSET when the seek implies
|
|
997
|
+
// no move).
|
|
998
|
+
if (mediaItemIndex < 0 || mediaItemIndex >= tracks.size) return false
|
|
999
|
+
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
1000
|
+
// Same path as the public skipToIndex: jump the receiver while
|
|
1001
|
+
// casting, else the local engine. Both write currentTrackIndex +
|
|
1002
|
+
// the USER_SKIP_TO_INDEX reason that a raw controller seek skips.
|
|
1003
|
+
// The tapped track starts at position 0 — the controller's
|
|
1004
|
+
// positionMs is intentionally not threaded through, matching the
|
|
1005
|
+
// public skipToIndex "play this track" contract.
|
|
1006
|
+
if (com.margelo.nitro.queueplayer.cast.CastTransportRouter.routeSkipToIndex(mediaItemIndex)) {
|
|
1007
|
+
return true
|
|
1008
|
+
}
|
|
1009
|
+
skipToIndexInternal(mediaItemIndex)
|
|
1010
|
+
return true
|
|
1011
|
+
}
|
|
1012
|
+
|
|
994
1013
|
override fun onMediaPlay() {
|
|
995
1014
|
pendingStateChangeReason = StateChangeReason.SYSTEM
|
|
996
1015
|
serviceBinder?.engine?.play()
|
|
@@ -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() {}
|
|
@@ -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
|
+
}
|
|
@@ -375,6 +375,76 @@ class PlaybackServiceCallbackTest {
|
|
|
375
375
|
}
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
@Test
|
|
379
|
+
fun `TrackPlayer onSeekToMediaItemCommand jumps to the tapped index and stamps reasons`() {
|
|
380
|
+
val player = newConfiguredPlayer()
|
|
381
|
+
try {
|
|
382
|
+
player.tracks = listOf(
|
|
383
|
+
TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
|
|
384
|
+
TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
|
|
385
|
+
TrackItem("c", "https://example.com/c.m4a", null, null, null, null, null, null),
|
|
386
|
+
)
|
|
387
|
+
player.currentTrackIndex = 0
|
|
388
|
+
|
|
389
|
+
val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(2)
|
|
390
|
+
|
|
391
|
+
assertTrue(
|
|
392
|
+
"an Android Auto queue-row tap is queue-aware and reports interception",
|
|
393
|
+
intercepted
|
|
394
|
+
)
|
|
395
|
+
assertEquals(2, player.currentTrackIndex)
|
|
396
|
+
assertEquals(StateChangeReason.SYSTEM, player.pendingStateChangeReason)
|
|
397
|
+
assertEquals(
|
|
398
|
+
TrackChangeReason.USER_SKIP_TO_INDEX, player.pendingTrackChangeReason
|
|
399
|
+
)
|
|
400
|
+
} finally {
|
|
401
|
+
tearDownPlayer(player)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
@Test
|
|
406
|
+
fun `TrackPlayer onSeekToMediaItemCommand declines an out-of-range index and leaves the current index`() {
|
|
407
|
+
val player = newConfiguredPlayer()
|
|
408
|
+
try {
|
|
409
|
+
player.tracks = listOf(
|
|
410
|
+
TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
|
|
411
|
+
TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
|
|
412
|
+
)
|
|
413
|
+
player.currentTrackIndex = 1
|
|
414
|
+
|
|
415
|
+
val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(5)
|
|
416
|
+
|
|
417
|
+
assertFalse(
|
|
418
|
+
"an out-of-range tap is declined so the default routing no-ops it",
|
|
419
|
+
intercepted
|
|
420
|
+
)
|
|
421
|
+
assertEquals(1, player.currentTrackIndex)
|
|
422
|
+
} finally {
|
|
423
|
+
tearDownPlayer(player)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
@Test
|
|
428
|
+
fun `TrackPlayer onSeekToMediaItemCommand on the current index is handled and keeps the index`() {
|
|
429
|
+
val player = newConfiguredPlayer()
|
|
430
|
+
try {
|
|
431
|
+
player.tracks = listOf(
|
|
432
|
+
TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
|
|
433
|
+
TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
|
|
434
|
+
)
|
|
435
|
+
player.currentTrackIndex = 1
|
|
436
|
+
|
|
437
|
+
val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(1)
|
|
438
|
+
|
|
439
|
+
// Tapping the playing row behaves like skipToIndex(current): handled,
|
|
440
|
+
// index unchanged, the engine restarts the track from the top.
|
|
441
|
+
assertTrue(intercepted)
|
|
442
|
+
assertEquals(1, player.currentTrackIndex)
|
|
443
|
+
} finally {
|
|
444
|
+
tearDownPlayer(player)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
378
448
|
// --- Voice / browse-leaf routing through onAddMediaItems / onSetMediaItems
|
|
379
449
|
|
|
380
450
|
@Test
|
|
@@ -759,6 +829,8 @@ class PlaybackServiceCallbackTest {
|
|
|
759
829
|
return skipPreviousHandled
|
|
760
830
|
}
|
|
761
831
|
|
|
832
|
+
override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
|
|
833
|
+
|
|
762
834
|
override fun getCachedSkipCapability(): SkipCapability = capability
|
|
763
835
|
|
|
764
836
|
// This suite covers the controller-driven callback routing only.
|
|
@@ -300,6 +300,8 @@ class PlaybackServiceLifecycleTest {
|
|
|
300
300
|
return true
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
|
|
304
|
+
|
|
303
305
|
override fun getCachedSkipCapability(): SkipCapability =
|
|
304
306
|
SkipCapability(canSkipPrevious = true, canSkipNext = true)
|
|
305
307
|
|
package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt
CHANGED
|
@@ -135,6 +135,37 @@ class SessionCommandForwardingPlayerTest {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// --- Controller seek-to-queue-item (Android Auto queue-row tap): queue-aware veto
|
|
139
|
+
// (COMMAND_SEEK_TO_MEDIA_ITEM routes through onSeekToMediaItemCommand rather than
|
|
140
|
+
// the raw per-item seek, so the lib's current index + track-change reason land.)
|
|
141
|
+
|
|
142
|
+
@Test
|
|
143
|
+
fun `controller seek-to-queue-item runs queue-aware skip and vetoes the default seek when handled`() {
|
|
144
|
+
val wrapped = RecordingPlayer()
|
|
145
|
+
val handler = RecordingHandler(seekToMediaItemHandled = true)
|
|
146
|
+
val wrapper = newWrapper(wrapped, handler)
|
|
147
|
+
|
|
148
|
+
asController { wrapper.handleSeek(2, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
|
|
149
|
+
|
|
150
|
+
assertEquals("seek-to-queue-item routes into onSeekToMediaItemCommand", 1, handler.seekToMediaItemCalls)
|
|
151
|
+
assertEquals("the tapped queue index is forwarded", 2, handler.lastSeekToMediaItemIndex)
|
|
152
|
+
assertTrue("a handled queue tap vetoes the default seek", wrapped.seekCommands.isEmpty())
|
|
153
|
+
assertEquals(0, handler.transportCalls)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@Test
|
|
157
|
+
fun `controller seek-to-queue-item falls through to the default seek when not handled`() {
|
|
158
|
+
val wrapped = RecordingPlayer()
|
|
159
|
+
val handler = RecordingHandler(seekToMediaItemHandled = false)
|
|
160
|
+
val wrapper = newWrapper(wrapped, handler)
|
|
161
|
+
|
|
162
|
+
asController { wrapper.handleSeek(2, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
|
|
163
|
+
|
|
164
|
+
assertEquals(1, handler.seekToMediaItemCalls)
|
|
165
|
+
assertEquals("a declined queue tap delegates one seek to the wrapped player",
|
|
166
|
+
listOf(Player.COMMAND_SEEK_TO_MEDIA_ITEM), wrapped.seekCommands)
|
|
167
|
+
}
|
|
168
|
+
|
|
138
169
|
// --- Controller transport seeks: stamp system then delegate
|
|
139
170
|
// (SEEK_BACK / SEEK_FORWARD are recomputed to the runtime interval — see the
|
|
140
171
|
// interval-skip tests above — so they are not part of this pass-through set.)
|
|
@@ -144,7 +175,6 @@ class SessionCommandForwardingPlayerTest {
|
|
|
144
175
|
val transportSeeks = listOf(
|
|
145
176
|
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
|
146
177
|
Player.COMMAND_SEEK_TO_DEFAULT_POSITION,
|
|
147
|
-
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
|
148
178
|
)
|
|
149
179
|
for (command in transportSeeks) {
|
|
150
180
|
val wrapped = RecordingPlayer()
|
|
@@ -319,6 +349,21 @@ class SessionCommandForwardingPlayerTest {
|
|
|
319
349
|
}
|
|
320
350
|
}
|
|
321
351
|
|
|
352
|
+
@Test
|
|
353
|
+
fun `non-controller seek-to-queue-item delegates straight through without consulting the handler`() {
|
|
354
|
+
val wrapped = RecordingPlayer()
|
|
355
|
+
val handler = RecordingHandler(seekToMediaItemHandled = true)
|
|
356
|
+
val wrapper = newWrapper(wrapped, handler)
|
|
357
|
+
|
|
358
|
+
// No controller-request scope: models the JS bridge / engine driving
|
|
359
|
+
// the player directly, which must never re-enter the skip path.
|
|
360
|
+
wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM)
|
|
361
|
+
|
|
362
|
+
assertEquals("internal seek never consults onSeekToMediaItemCommand", 0, handler.seekToMediaItemCalls)
|
|
363
|
+
assertEquals("internal seek delegates straight to the wrapped player",
|
|
364
|
+
listOf(Player.COMMAND_SEEK_TO_MEDIA_ITEM), wrapped.seekCommands)
|
|
365
|
+
}
|
|
366
|
+
|
|
322
367
|
@Test
|
|
323
368
|
fun `non-controller transport delegates straight through without consulting the handler`() {
|
|
324
369
|
val wrapped = RecordingPlayer()
|
|
@@ -344,16 +389,18 @@ class SessionCommandForwardingPlayerTest {
|
|
|
344
389
|
|
|
345
390
|
asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_NEXT) }
|
|
346
391
|
asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_PREVIOUS) }
|
|
392
|
+
asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
|
|
347
393
|
asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_BACK) }
|
|
348
394
|
asController { wrapper.handleSetPlayWhenReady(true) }
|
|
349
395
|
asController { wrapper.handleSetPlaybackParameters(PlaybackParameters(1.25f)) }
|
|
350
396
|
|
|
351
|
-
// With no handler the skip cannot be vetoed, so all
|
|
397
|
+
// With no handler the skip / queue-tap cannot be vetoed, so all delegate.
|
|
352
398
|
// SEEK_BACK is recomputed to the interval and forwarded as an in-item seek.
|
|
353
399
|
assertEquals(
|
|
354
400
|
listOf(
|
|
355
401
|
Player.COMMAND_SEEK_TO_NEXT,
|
|
356
402
|
Player.COMMAND_SEEK_TO_PREVIOUS,
|
|
403
|
+
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
|
|
357
404
|
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
|
|
358
405
|
),
|
|
359
406
|
wrapped.seekCommands
|
|
@@ -482,10 +529,13 @@ class SessionCommandForwardingPlayerTest {
|
|
|
482
529
|
private class RecordingHandler(
|
|
483
530
|
private val skipNextHandled: Boolean = true,
|
|
484
531
|
private val skipPreviousHandled: Boolean = true,
|
|
532
|
+
private val seekToMediaItemHandled: Boolean = true,
|
|
485
533
|
) : PlaybackService.MediaSessionCommandHandler {
|
|
486
534
|
var transportCalls = 0
|
|
487
535
|
var skipNextCalls = 0
|
|
488
536
|
var skipPreviousCalls = 0
|
|
537
|
+
var seekToMediaItemCalls = 0
|
|
538
|
+
var lastSeekToMediaItemIndex = Int.MIN_VALUE
|
|
489
539
|
|
|
490
540
|
override fun onTransportCommand() {
|
|
491
541
|
transportCalls++
|
|
@@ -501,6 +551,12 @@ class SessionCommandForwardingPlayerTest {
|
|
|
501
551
|
return skipPreviousHandled
|
|
502
552
|
}
|
|
503
553
|
|
|
554
|
+
override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean {
|
|
555
|
+
seekToMediaItemCalls++
|
|
556
|
+
lastSeekToMediaItemIndex = mediaItemIndex
|
|
557
|
+
return seekToMediaItemHandled
|
|
558
|
+
}
|
|
559
|
+
|
|
504
560
|
override fun getCachedSkipCapability(): SkipCapability =
|
|
505
561
|
SkipCapability(canSkipPrevious = true, canSkipNext = true)
|
|
506
562
|
|
|
@@ -29,17 +29,27 @@ final class CarPlayCoordinator {
|
|
|
29
29
|
static let shared = CarPlayCoordinator()
|
|
30
30
|
|
|
31
31
|
let dataProvider: CarPlayBrowseDataProvider
|
|
32
|
+
let queueProvider: CarPlayQueueProvider
|
|
32
33
|
let bridge: CarPlayBridge
|
|
33
34
|
let pending: PendingBrowseRequests
|
|
34
35
|
let loader: ArtworkLoader
|
|
35
36
|
|
|
36
37
|
private init() {
|
|
37
38
|
self.dataProvider = CarPlayBrowseDataProvider()
|
|
39
|
+
self.queueProvider = CarPlayQueueProvider()
|
|
38
40
|
self.bridge = CarPlayBridge()
|
|
39
41
|
self.pending = PendingBrowseRequests()
|
|
40
42
|
self.loader = ArtworkLoader()
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// MARK: - Queue skip
|
|
46
|
+
|
|
47
|
+
/// Skip playback to a queue index. `TrackPlayer` registers this at
|
|
48
|
+
/// configure; the CarPlay scene delegate invokes it when the user
|
|
49
|
+
/// taps a row in the Up Next list. Set and called on the main thread
|
|
50
|
+
/// only, so no lock is needed.
|
|
51
|
+
var skipToIndexHandler: ((Int) -> Void)?
|
|
52
|
+
|
|
43
53
|
// MARK: - Connection state
|
|
44
54
|
|
|
45
55
|
/// Whether the CarPlay scene is currently connected. Written by
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// Convert a [CarPlayQueueSnapshot] into the CarPlay "Up Next" list
|
|
5
|
+
/// template shown when the user taps the Now Playing queue button.
|
|
6
|
+
/// Single-section `CPListTemplate` — one `CPListItem` per queue entry,
|
|
7
|
+
/// with the currently-playing entry marked via `isPlaying`.
|
|
8
|
+
///
|
|
9
|
+
/// Audio-app list cap: CarPlay reports the runtime row limit via
|
|
10
|
+
/// `CPListTemplate.maximumItemCount`. Over-cap queues are truncated to
|
|
11
|
+
/// the first N rows; the current entry's index still maps 1:1 to the
|
|
12
|
+
/// list row as long as it falls inside the cap.
|
|
13
|
+
///
|
|
14
|
+
/// Stateless helper. The owning scene delegate pushes the returned
|
|
15
|
+
/// template onto the interface controller.
|
|
16
|
+
final class CarPlayQueueBuilder {
|
|
17
|
+
|
|
18
|
+
/// Maximum rows per Up Next list. Reads the SDK property so the cap
|
|
19
|
+
/// tracks whatever the running CarPlay framework enforces.
|
|
20
|
+
static var maximumItemCount: Int { CPListTemplate.maximumItemCount }
|
|
21
|
+
|
|
22
|
+
/// Build the Up Next `CPListTemplate` from [snapshot]. Each row's
|
|
23
|
+
/// tap handler invokes [skip] with that row's queue index, then the
|
|
24
|
+
/// caller pops back to Now Playing. The entry at
|
|
25
|
+
/// `snapshot.currentIndex` is marked `isPlaying`. Rows beyond
|
|
26
|
+
/// [maximumItemCount] are dropped.
|
|
27
|
+
func buildQueueTemplate(
|
|
28
|
+
snapshot: CarPlayQueueSnapshot,
|
|
29
|
+
loader: ArtworkLoader,
|
|
30
|
+
maximumItemCount: Int = CarPlayQueueBuilder.maximumItemCount,
|
|
31
|
+
skip: @escaping (Int) -> Void
|
|
32
|
+
) -> CPListTemplate {
|
|
33
|
+
let cap = max(0, maximumItemCount)
|
|
34
|
+
let visible = snapshot.entries.prefix(cap)
|
|
35
|
+
let items = visible.enumerated().map { index, entry in
|
|
36
|
+
makeQueueItem(
|
|
37
|
+
entry: entry,
|
|
38
|
+
index: index,
|
|
39
|
+
isCurrent: index == snapshot.currentIndex,
|
|
40
|
+
loader: loader,
|
|
41
|
+
skip: skip
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return CPListTemplate(
|
|
45
|
+
title: upNextTitle,
|
|
46
|
+
sections: [CPListSection(items: items)]
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Build a single Up Next row. Text = entry title, detail = subtitle.
|
|
51
|
+
/// Tap fires [skip] with [index]. The current row shows the playing
|
|
52
|
+
/// indicator; artwork loads asynchronously via [loader].
|
|
53
|
+
func makeQueueItem(
|
|
54
|
+
entry: CarPlayQueueEntry,
|
|
55
|
+
index: Int,
|
|
56
|
+
isCurrent: Bool,
|
|
57
|
+
loader: ArtworkLoader,
|
|
58
|
+
skip: @escaping (Int) -> Void
|
|
59
|
+
) -> CPListItem {
|
|
60
|
+
let listItem = CPListItem(
|
|
61
|
+
text: entry.title,
|
|
62
|
+
detailText: entry.subtitle,
|
|
63
|
+
image: nil
|
|
64
|
+
)
|
|
65
|
+
listItem.isPlaying = isCurrent
|
|
66
|
+
if let artworkUrl = entry.artworkUrl, !artworkUrl.isEmpty {
|
|
67
|
+
loader.load(uri: artworkUrl) { [weak listItem] image in
|
|
68
|
+
guard let listItem = listItem, let image = image else { return }
|
|
69
|
+
listItem.setImage(image)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
listItem.handler = { _, completion in
|
|
73
|
+
skip(index)
|
|
74
|
+
completion()
|
|
75
|
+
}
|
|
76
|
+
return listItem
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private var upNextTitle: String {
|
|
80
|
+
NSLocalizedString("Up Next", comment: "CarPlay queue list title")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One queue entry mirrored for the CarPlay Up Next surface. A plain
|
|
4
|
+
/// value type decoupled from the Nitro `TrackItem` — the mirror holds
|
|
5
|
+
/// only what the list template renders, so the CarPlay scene reads
|
|
6
|
+
/// display data without reaching back into the Hybrid.
|
|
7
|
+
struct CarPlayQueueEntry {
|
|
8
|
+
let title: String
|
|
9
|
+
let subtitle: String?
|
|
10
|
+
let artworkUrl: String?
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Immutable mirror of the live play queue for the CarPlay Up Next
|
|
14
|
+
/// list. `currentIndex` is the index of the playing track, or -1 when
|
|
15
|
+
/// nothing is current. Snapshot replacement is atomic (see
|
|
16
|
+
/// [CarPlayQueueProvider]).
|
|
17
|
+
struct CarPlayQueueSnapshot {
|
|
18
|
+
let entries: [CarPlayQueueEntry]
|
|
19
|
+
let currentIndex: Int
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Native cache for the most-recent [CarPlayQueueSnapshot] pushed by
|
|
23
|
+
/// `TrackPlayer` whenever the queue or current index changes. The
|
|
24
|
+
/// CarPlay scene delegate reads from this cache to build the Up Next
|
|
25
|
+
/// list so a queue-button tap does not have to round-trip through JS.
|
|
26
|
+
///
|
|
27
|
+
/// Snapshot replacement is atomic: a single `NSLock` guards the stored
|
|
28
|
+
/// reference. Concurrent readers see either the prior snapshot in full
|
|
29
|
+
/// or the new one in full, never a torn mix.
|
|
30
|
+
///
|
|
31
|
+
/// Update notifications: a `Notification` named
|
|
32
|
+
/// [CarPlayQueueProvider.didUpdateNotification] fires on the main queue
|
|
33
|
+
/// after every `setSnapshot`. The scene delegate observes it to
|
|
34
|
+
/// re-evaluate the Up Next button's enabled state as the queue changes.
|
|
35
|
+
final class CarPlayQueueProvider {
|
|
36
|
+
|
|
37
|
+
/// Notification posted on the main queue after `setSnapshot`.
|
|
38
|
+
static let didUpdateNotification = Notification.Name(
|
|
39
|
+
"com.margelo.nitro.queueplayer.CarPlayQueueProvider.didUpdate"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/// Replace the cached snapshot. Pass `nil` to clear. Posts
|
|
43
|
+
/// [didUpdateNotification] on the main queue (dispatched to main if
|
|
44
|
+
/// the caller is off-main) so observers re-evaluate against the new
|
|
45
|
+
/// snapshot on a stable thread.
|
|
46
|
+
func setSnapshot(_ snapshot: CarPlayQueueSnapshot?) {
|
|
47
|
+
lock.lock()
|
|
48
|
+
self.snapshot = snapshot
|
|
49
|
+
lock.unlock()
|
|
50
|
+
|
|
51
|
+
let notify = {
|
|
52
|
+
NotificationCenter.default.post(
|
|
53
|
+
name: CarPlayQueueProvider.didUpdateNotification,
|
|
54
|
+
object: self
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
if Thread.isMainThread {
|
|
58
|
+
notify()
|
|
59
|
+
} else {
|
|
60
|
+
DispatchQueue.main.async(execute: notify)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Read the current snapshot, or `nil` if none has been set.
|
|
65
|
+
func getSnapshot() -> CarPlayQueueSnapshot? {
|
|
66
|
+
lock.lock()
|
|
67
|
+
defer { lock.unlock() }
|
|
68
|
+
return snapshot
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private var snapshot: CarPlayQueueSnapshot?
|
|
72
|
+
private let lock = NSLock()
|
|
73
|
+
}
|
|
@@ -52,11 +52,19 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
|
|
|
52
52
|
/// delegate doesn't leak past its scene's lifetime.
|
|
53
53
|
private var snapshotObserver: NSObjectProtocol?
|
|
54
54
|
|
|
55
|
+
/// Queue-mirror observer. Fires when the play queue or current index
|
|
56
|
+
/// changes so the Up Next button's enabled state re-evaluates.
|
|
57
|
+
/// Removed on scene disconnect.
|
|
58
|
+
private var queueObserver: NSObjectProtocol?
|
|
59
|
+
|
|
55
60
|
/// Cached scene-construction template builder. Stateless; the
|
|
56
61
|
/// live tab-bar reference above is what survives across snapshot
|
|
57
62
|
/// updates.
|
|
58
63
|
private let builder = CarPlayBrowseBuilder()
|
|
59
64
|
|
|
65
|
+
/// Stateless builder for the Up Next queue list template.
|
|
66
|
+
private let queueBuilder = CarPlayQueueBuilder()
|
|
67
|
+
|
|
60
68
|
// MARK: - CPTemplateApplicationSceneDelegate
|
|
61
69
|
|
|
62
70
|
public func templateApplicationScene(
|
|
@@ -122,6 +130,14 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
|
|
|
122
130
|
self?.refreshRootTemplate()
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
queueObserver = NotificationCenter.default.addObserver(
|
|
134
|
+
forName: CarPlayQueueProvider.didUpdateNotification,
|
|
135
|
+
object: coordinator.queueProvider,
|
|
136
|
+
queue: .main
|
|
137
|
+
) { [weak self] _ in
|
|
138
|
+
self?.refreshUpNextButton()
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
coordinator.setCarConnected(true)
|
|
126
142
|
coordinator.bridge.carConnectCallback()?()
|
|
127
143
|
}
|
|
@@ -138,6 +154,11 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
|
|
|
138
154
|
NotificationCenter.default.removeObserver(observer)
|
|
139
155
|
snapshotObserver = nil
|
|
140
156
|
}
|
|
157
|
+
if let observer = queueObserver {
|
|
158
|
+
NotificationCenter.default.removeObserver(observer)
|
|
159
|
+
queueObserver = nil
|
|
160
|
+
}
|
|
161
|
+
CPNowPlayingTemplate.shared.remove(self)
|
|
141
162
|
self.interfaceController = nil
|
|
142
163
|
self.rootTabBar = nil
|
|
143
164
|
CarPlayCoordinator.shared.setCarConnected(false)
|
|
@@ -188,14 +209,65 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
|
|
|
188
209
|
/// nav stack via `pushTemplate(CPNowPlayingTemplate.shared, ...)`
|
|
189
210
|
/// is the supported path for `com.apple.developer.carplay-audio`
|
|
190
211
|
/// apps; embedding the same template in a `CPTabBarTemplate` root
|
|
191
|
-
/// is NOT — that raises `NSInvalidArgumentException`.
|
|
192
|
-
///
|
|
193
|
-
///
|
|
194
|
-
///
|
|
195
|
-
///
|
|
212
|
+
/// is NOT — that raises `NSInvalidArgumentException`.
|
|
213
|
+
///
|
|
214
|
+
/// Registers self as a `CPNowPlayingTemplateObserver` so the Up Next
|
|
215
|
+
/// button tap routes into [nowPlayingTemplateUpNextButtonTapped].
|
|
216
|
+
/// Enables only the Up Next flag + title (when the mirrored queue is
|
|
217
|
+
/// non-empty); every other Now Playing button keeps baseline defaults.
|
|
196
218
|
private func configureNowPlayingTemplate() {
|
|
197
|
-
|
|
198
|
-
|
|
219
|
+
CPNowPlayingTemplate.shared.add(self)
|
|
220
|
+
refreshUpNextButton()
|
|
221
|
+
NSLog("[RNQP-CarPlay] CPNowPlayingTemplate.shared configured")
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Enable the Up Next button when the mirrored queue has entries.
|
|
225
|
+
/// Re-run from the queue-mirror observer so the button reflects the
|
|
226
|
+
/// live queue.
|
|
227
|
+
private func refreshUpNextButton() {
|
|
228
|
+
let hasQueue = !(
|
|
229
|
+
CarPlayCoordinator.shared.queueProvider.getSnapshot()?.entries.isEmpty ?? true
|
|
230
|
+
)
|
|
231
|
+
CPNowPlayingTemplate.shared.isUpNextButtonEnabled = hasQueue
|
|
232
|
+
CPNowPlayingTemplate.shared.upNextTitle = upNextTitle
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private var upNextTitle: String {
|
|
236
|
+
NSLocalizedString("Up Next", comment: "CarPlay queue button title")
|
|
199
237
|
}
|
|
200
238
|
|
|
201
239
|
}
|
|
240
|
+
|
|
241
|
+
// MARK: - CPNowPlayingTemplateObserver
|
|
242
|
+
|
|
243
|
+
@available(iOS 14.0, *)
|
|
244
|
+
extension CarPlaySceneDelegate: CPNowPlayingTemplateObserver {
|
|
245
|
+
|
|
246
|
+
/// Build the Up Next list from the mirrored queue snapshot and push
|
|
247
|
+
/// it. Each row taps back to Now Playing after routing the skip
|
|
248
|
+
/// through the coordinator's `skipToIndexHandler`, which `TrackPlayer`
|
|
249
|
+
/// registers. No-op when no queue is mirrored yet.
|
|
250
|
+
func nowPlayingTemplateUpNextButtonTapped(
|
|
251
|
+
_ nowPlayingTemplate: CPNowPlayingTemplate
|
|
252
|
+
) {
|
|
253
|
+
guard let controller = interfaceController else { return }
|
|
254
|
+
// Only push from the Now Playing surface — guards against a
|
|
255
|
+
// spurious double-push if the tap re-fires while the list is up.
|
|
256
|
+
guard controller.topTemplate === CPNowPlayingTemplate.shared else { return }
|
|
257
|
+
guard let snapshot = CarPlayCoordinator.shared.queueProvider.getSnapshot(),
|
|
258
|
+
!snapshot.entries.isEmpty else {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
let list = queueBuilder.buildQueueTemplate(
|
|
262
|
+
snapshot: snapshot,
|
|
263
|
+
loader: CarPlayCoordinator.shared.loader,
|
|
264
|
+
skip: { [weak self] index in
|
|
265
|
+
CarPlayCoordinator.shared.skipToIndexHandler?(index)
|
|
266
|
+
// Return to Now Playing — pop the Up Next list off the nav
|
|
267
|
+
// stack; the Now Playing surface is already beneath it.
|
|
268
|
+
self?.interfaceController?.popTemplate(animated: true) { _, _ in }
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
controller.pushTemplate(list, animated: true) { _, _ in }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import XCTest
|
|
3
|
+
@testable import QueuePlayer
|
|
4
|
+
|
|
5
|
+
/// Coverage for [CarPlayQueueBuilder] — snapshot → Up Next template
|
|
6
|
+
/// conversion, row count + title/detail mapping, truncation at
|
|
7
|
+
/// `CPListTemplate.maximumItemCount`, current-index marking, and the
|
|
8
|
+
/// row-tap skip closure — plus a [CarPlayQueueProvider] round-trip.
|
|
9
|
+
final class CarPlayQueueBuilderTests: XCTestCase {
|
|
10
|
+
|
|
11
|
+
private func entry(
|
|
12
|
+
_ title: String,
|
|
13
|
+
subtitle: String? = nil,
|
|
14
|
+
artworkUrl: String? = nil
|
|
15
|
+
) -> CarPlayQueueEntry {
|
|
16
|
+
CarPlayQueueEntry(title: title, subtitle: subtitle, artworkUrl: artworkUrl)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private func snapshot(
|
|
20
|
+
_ entries: [CarPlayQueueEntry],
|
|
21
|
+
currentIndex: Int
|
|
22
|
+
) -> CarPlayQueueSnapshot {
|
|
23
|
+
CarPlayQueueSnapshot(entries: entries, currentIndex: currentIndex)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// MARK: - Template construction
|
|
27
|
+
|
|
28
|
+
func testBuildQueueTemplateMapsEntriesToRows() {
|
|
29
|
+
let builder = CarPlayQueueBuilder()
|
|
30
|
+
let loader = ArtworkLoader()
|
|
31
|
+
let template = builder.buildQueueTemplate(
|
|
32
|
+
snapshot: snapshot(
|
|
33
|
+
[
|
|
34
|
+
entry("Everlong", subtitle: "Foo Fighters"),
|
|
35
|
+
entry("Monkey Wrench", subtitle: "Foo Fighters"),
|
|
36
|
+
],
|
|
37
|
+
currentIndex: 0
|
|
38
|
+
),
|
|
39
|
+
loader: loader,
|
|
40
|
+
skip: { _ in }
|
|
41
|
+
)
|
|
42
|
+
let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
|
|
43
|
+
XCTAssertEqual(items.count, 2)
|
|
44
|
+
XCTAssertEqual(items[0].text, "Everlong")
|
|
45
|
+
XCTAssertEqual(items[0].detailText, "Foo Fighters")
|
|
46
|
+
XCTAssertEqual(items[1].text, "Monkey Wrench")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func testBuildQueueTemplateCapsRowsAtMaximumItemCount() {
|
|
50
|
+
let builder = CarPlayQueueBuilder()
|
|
51
|
+
let loader = ArtworkLoader()
|
|
52
|
+
let cap = 5
|
|
53
|
+
let entries = (0..<cap + 4).map { entry("T-\($0)") }
|
|
54
|
+
let template = builder.buildQueueTemplate(
|
|
55
|
+
snapshot: snapshot(entries, currentIndex: 0),
|
|
56
|
+
loader: loader,
|
|
57
|
+
maximumItemCount: cap,
|
|
58
|
+
skip: { _ in }
|
|
59
|
+
)
|
|
60
|
+
let items = template.sections.first?.items ?? []
|
|
61
|
+
XCTAssertEqual(items.count, cap)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func testBuildQueueTemplateMarksCurrentIndex() {
|
|
65
|
+
let builder = CarPlayQueueBuilder()
|
|
66
|
+
let loader = ArtworkLoader()
|
|
67
|
+
let template = builder.buildQueueTemplate(
|
|
68
|
+
snapshot: snapshot(
|
|
69
|
+
[entry("A"), entry("B"), entry("C")],
|
|
70
|
+
currentIndex: 1
|
|
71
|
+
),
|
|
72
|
+
loader: loader,
|
|
73
|
+
skip: { _ in }
|
|
74
|
+
)
|
|
75
|
+
let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
|
|
76
|
+
XCTAssertFalse(items[0].isPlaying)
|
|
77
|
+
XCTAssertTrue(items[1].isPlaying)
|
|
78
|
+
XCTAssertFalse(items[2].isPlaying)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func testBuildQueueTemplateMarksNothingWhenIndexOutOfRange() {
|
|
82
|
+
let builder = CarPlayQueueBuilder()
|
|
83
|
+
let loader = ArtworkLoader()
|
|
84
|
+
let template = builder.buildQueueTemplate(
|
|
85
|
+
snapshot: snapshot([entry("A"), entry("B")], currentIndex: -1),
|
|
86
|
+
loader: loader,
|
|
87
|
+
skip: { _ in }
|
|
88
|
+
)
|
|
89
|
+
let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
|
|
90
|
+
XCTAssertTrue(items.allSatisfy { !$0.isPlaying })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// MARK: - Row-tap skip
|
|
94
|
+
|
|
95
|
+
func testRowTapInvokesSkipWithRowIndex() {
|
|
96
|
+
let builder = CarPlayQueueBuilder()
|
|
97
|
+
let loader = ArtworkLoader()
|
|
98
|
+
var skippedTo: Int?
|
|
99
|
+
let template = builder.buildQueueTemplate(
|
|
100
|
+
snapshot: snapshot([entry("A"), entry("B"), entry("C")], currentIndex: 0),
|
|
101
|
+
loader: loader,
|
|
102
|
+
skip: { skippedTo = $0 }
|
|
103
|
+
)
|
|
104
|
+
let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
|
|
105
|
+
let exp = expectation(description: "row handler completes")
|
|
106
|
+
items[2].handler?(items[2]) { exp.fulfill() }
|
|
107
|
+
wait(for: [exp], timeout: 2)
|
|
108
|
+
XCTAssertEqual(skippedTo, 2)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - Provider round-trip
|
|
112
|
+
|
|
113
|
+
func testProviderSetSnapshotMakesSnapshotReadable() {
|
|
114
|
+
let provider = CarPlayQueueProvider()
|
|
115
|
+
XCTAssertNil(provider.getSnapshot())
|
|
116
|
+
provider.setSnapshot(
|
|
117
|
+
snapshot([entry("A", subtitle: "sub")], currentIndex: 0)
|
|
118
|
+
)
|
|
119
|
+
let read = provider.getSnapshot()
|
|
120
|
+
XCTAssertEqual(read?.entries.count, 1)
|
|
121
|
+
XCTAssertEqual(read?.entries.first?.title, "A")
|
|
122
|
+
XCTAssertEqual(read?.entries.first?.subtitle, "sub")
|
|
123
|
+
XCTAssertEqual(read?.currentIndex, 0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func testProviderSetSnapshotNilClearsCache() {
|
|
127
|
+
let provider = CarPlayQueueProvider()
|
|
128
|
+
provider.setSnapshot(snapshot([entry("A")], currentIndex: 0))
|
|
129
|
+
provider.setSnapshot(nil)
|
|
130
|
+
XCTAssertNil(provider.getSnapshot())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func testProviderSetSnapshotPostsDidUpdateNotification() {
|
|
134
|
+
let provider = CarPlayQueueProvider()
|
|
135
|
+
let exp = expectation(
|
|
136
|
+
forNotification: CarPlayQueueProvider.didUpdateNotification,
|
|
137
|
+
object: provider,
|
|
138
|
+
handler: nil
|
|
139
|
+
)
|
|
140
|
+
DispatchQueue.global().async {
|
|
141
|
+
provider.setSnapshot(self.snapshot([self.entry("A")], currentIndex: 0))
|
|
142
|
+
}
|
|
143
|
+
wait(for: [exp], timeout: 2)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -33,7 +33,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
33
33
|
samples.withUnsafeBufferPointer { p in
|
|
34
34
|
proc.ingest(samples: p.baseAddress!, frameCount: n, sampleRate: 48000)
|
|
35
35
|
}
|
|
36
|
-
wait(for: [exp], timeout:
|
|
36
|
+
wait(for: [exp], timeout: 5.0)
|
|
37
37
|
XCTAssertEqual(capturedSampleRate, 48000, accuracy: 0.001)
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -57,7 +57,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
57
57
|
rest.withUnsafeBufferPointer { p in
|
|
58
58
|
proc.ingest(samples: p.baseAddress!, frameCount: 256, sampleRate: 44100)
|
|
59
59
|
}
|
|
60
|
-
wait(for: [emitted], timeout:
|
|
60
|
+
wait(for: [emitted], timeout: 5.0)
|
|
61
61
|
XCTAssertEqual(calls, 1)
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -74,7 +74,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
74
74
|
buffer.withUnsafeBufferPointer { p in
|
|
75
75
|
proc.ingest(samples: p.baseAddress!, frameCount: 128, sampleRate: 44100)
|
|
76
76
|
}
|
|
77
|
-
wait(for: [exp], timeout:
|
|
77
|
+
wait(for: [exp], timeout: 5.0)
|
|
78
78
|
let samples = try XCTUnwrap(capturedSamples)
|
|
79
79
|
XCTAssertEqual(samples.size, 128 * MemoryLayout<Float>.size)
|
|
80
80
|
}
|
|
@@ -94,7 +94,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
94
94
|
buffer.withUnsafeBufferPointer { p in
|
|
95
95
|
proc.ingest(samples: p.baseAddress!, frameCount: 128, sampleRate: 44100)
|
|
96
96
|
}
|
|
97
|
-
wait(for: [exp], timeout:
|
|
97
|
+
wait(for: [exp], timeout: 5.0)
|
|
98
98
|
XCTAssertTrue(captured)
|
|
99
99
|
XCTAssertNil(capturedSamples)
|
|
100
100
|
}
|
|
@@ -117,7 +117,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
117
117
|
samples.withUnsafeBufferPointer { p in
|
|
118
118
|
proc.ingest(samples: p.baseAddress!, frameCount: n, sampleRate: 48000)
|
|
119
119
|
}
|
|
120
|
-
wait(for: [exp], timeout:
|
|
120
|
+
wait(for: [exp], timeout: 5.0)
|
|
121
121
|
let fft = try XCTUnwrap(capturedFft)
|
|
122
122
|
let mags = arrayBufferToFloats(fft)
|
|
123
123
|
let peakBin = mags.indices.max(by: { mags[$0] < mags[$1] }) ?? -1
|
|
@@ -205,7 +205,7 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
205
205
|
closure(leftIn, leftOut, n, 1, 0, 2)
|
|
206
206
|
closure(rightIn, rightOut, n, 1, 1, 2)
|
|
207
207
|
|
|
208
|
-
wait(for: [exp], timeout:
|
|
208
|
+
wait(for: [exp], timeout: 5.0)
|
|
209
209
|
let samples = try XCTUnwrap(capturedSamples)
|
|
210
210
|
XCTAssertEqual(samples.size, n * MemoryLayout<Float>.size)
|
|
211
211
|
|
|
@@ -233,7 +233,9 @@ final class FFTProcessorTests: XCTestCase {
|
|
|
233
233
|
}
|
|
234
234
|
let exp = expectation(description: "drained")
|
|
235
235
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() }
|
|
236
|
-
|
|
236
|
+
// Generous timeout: the 0.1s drain only needs to outlast the 1000ms
|
|
237
|
+
// throttle window; the ceiling is headroom for a loaded CI main queue.
|
|
238
|
+
wait(for: [exp], timeout: 10.0)
|
|
237
239
|
XCTAssertEqual(calls, 1, "second back-to-back call must be throttled")
|
|
238
240
|
}
|
|
239
241
|
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -587,6 +587,22 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
587
587
|
canSkipNext: self.cachedSkipCapability.canSkipNext,
|
|
588
588
|
canSkipPrevious: self.cachedSkipCapability.canSkipPrevious
|
|
589
589
|
)
|
|
590
|
+
// Route CarPlay Up Next row taps through the same steps the
|
|
591
|
+
// `skipToIndex` Hybrid runs — engine guard, index validation,
|
|
592
|
+
// and cast routing (a tap during an active cast session jumps
|
|
593
|
+
// the RECEIVER; the local engine stays paused, so a local index
|
|
594
|
+
// write would desync from it) — then the local index write.
|
|
595
|
+
// Set + invoked on main; the scene delegate calls it from the
|
|
596
|
+
// Up Next row handler.
|
|
597
|
+
CarPlayCoordinator.shared.skipToIndexHandler = { [weak self] index in
|
|
598
|
+
guard let self = self else { return }
|
|
599
|
+
guard self.engine != nil else { return }
|
|
600
|
+
guard let target = InputGuards.validQueueIndex(
|
|
601
|
+
Double(index), count: self.tracks.count
|
|
602
|
+
) else { return }
|
|
603
|
+
if CastTransportRouter.routeSkipToIndex(index: target) { return }
|
|
604
|
+
self.applyNewCurrentIndex(newIndex: target, reason: .userSkipToIndex)
|
|
605
|
+
}
|
|
590
606
|
// Refresh on every configure() so a header / UA update lands
|
|
591
607
|
// on the next artwork resolve. Live config — read fresh on
|
|
592
608
|
// each track-change resolve.
|
|
@@ -891,6 +907,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
891
907
|
// configure cycle.
|
|
892
908
|
let coordinator = CarPlayCoordinator.shared
|
|
893
909
|
coordinator.dataProvider.setSnapshot(nil)
|
|
910
|
+
coordinator.queueProvider.setSnapshot(nil)
|
|
911
|
+
coordinator.skipToIndexHandler = nil
|
|
894
912
|
coordinator.pending.clear()
|
|
895
913
|
coordinator.loader.clearCache()
|
|
896
914
|
// engine.release() drains the underlying AVQueuePlayer's items +
|
|
@@ -2089,6 +2107,11 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
2089
2107
|
/// - `destroy` (resets to (false, false) so a final emit lets
|
|
2090
2108
|
/// subscribers know the player is gone)
|
|
2091
2109
|
internal func recomputeCapabilities() {
|
|
2110
|
+
// Every queue / current-index / repeat-mode change funnels through here,
|
|
2111
|
+
// so it's the single point that keeps the CarPlay Up Next mirror current
|
|
2112
|
+
// — done before the skip-capability dedup guard below so reorder/metadata
|
|
2113
|
+
// changes (which don't flip skip capability) still refresh the mirror.
|
|
2114
|
+
syncCarPlayQueueMirror()
|
|
2092
2115
|
let mode = repeatModeAsSkipMode()
|
|
2093
2116
|
let count = tracks.count
|
|
2094
2117
|
let cur = currentTrackIndex
|
|
@@ -2109,6 +2132,30 @@ class TrackPlayer: HybridTrackPlayerSpec {
|
|
|
2109
2132
|
)
|
|
2110
2133
|
}
|
|
2111
2134
|
|
|
2135
|
+
/// Mirror the live queue + current index into the process-wide
|
|
2136
|
+
/// [CarPlayQueueProvider] so the CarPlay Up Next surface renders the
|
|
2137
|
+
/// current playback queue without round-tripping through JS. Driven
|
|
2138
|
+
/// from `recomputeCapabilities()`, which every queue / current-index /
|
|
2139
|
+
/// repeat-mode change funnels through. An empty queue clears the
|
|
2140
|
+
/// mirror to `nil` so the Up Next button hides. Main-thread only
|
|
2141
|
+
/// (queue state is main-bound).
|
|
2142
|
+
internal func syncCarPlayQueueMirror() {
|
|
2143
|
+
guard !self.tracks.isEmpty else {
|
|
2144
|
+
CarPlayCoordinator.shared.queueProvider.setSnapshot(nil)
|
|
2145
|
+
return
|
|
2146
|
+
}
|
|
2147
|
+
let entries = self.tracks.map { track in
|
|
2148
|
+
CarPlayQueueEntry(
|
|
2149
|
+
title: track.title ?? "",
|
|
2150
|
+
subtitle: track.artist ?? track.album,
|
|
2151
|
+
artworkUrl: track.artworkUrl
|
|
2152
|
+
)
|
|
2153
|
+
}
|
|
2154
|
+
CarPlayCoordinator.shared.queueProvider.setSnapshot(
|
|
2155
|
+
CarPlayQueueSnapshot(entries: entries, currentIndex: self.currentTrackIndex)
|
|
2156
|
+
)
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2112
2159
|
/// Apply a new `currentTrackIndex` and sync the AVQueuePlayer.
|
|
2113
2160
|
///
|
|
2114
2161
|
/// **Forward skip** (newIndex > oldIndex) — surgical trim of items
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-queue-player",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/index.d.ts",
|