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.
@@ -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 compact notification view shows three buttons in priority
792
- * order: prev / play-pause / next. Media3 1.9+ resolves these from
793
- * standard player commands set via [CommandButton.Builder.setPlayerCommand]
794
- * no custom command handlers required. The expanded view shows
795
- * the same three buttons plus the scrub progress bar that
796
- * `DefaultMediaNotificationProvider` adds automatically when the
797
- * session reports a known duration.
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 return true
1650
- * when the implementation has handled the skip itself, so the
1651
- * default routing is suppressed; the transport hook fires for play /
1652
- * pause / seek / setPlaybackSpeed and is purely a side-channel
1653
- * notification (no return value).
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,
@@ -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
 
@@ -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 five delegate.
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`. Property
192
- /// mutation here is intentionally avoided — earlier iOS 26 sim
193
- /// experiments setting `isUpNextButtonEnabled = false` /
194
- /// `isAlbumArtistButtonEnabled = false` left the template rendering
195
- /// blank; baseline defaults work.
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
- _ = CPNowPlayingTemplate.shared
198
- NSLog("[RNQP-CarPlay] CPNowPlayingTemplate.shared touched")
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: 1.0)
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: 1.0)
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: 1.0)
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: 1.0)
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: 1.0)
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: 1.0)
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
- wait(for: [exp], timeout: 1.0)
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
 
@@ -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.4",
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",