react-native-queue-player 1.0.4 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
  11. package/android/src/main/res/values/strings.xml +0 -3
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
  25. package/ios/CarPlayCoordinator.swift +10 -0
  26. package/ios/CarPlayQueueBuilder.swift +82 -0
  27. package/ios/CarPlayQueueProvider.swift +73 -0
  28. package/ios/CarPlaySceneDelegate.swift +79 -7
  29. package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
  30. package/ios/Tests/FFTProcessorTests.swift +9 -7
  31. package/ios/TrackPlayer.swift +47 -0
  32. package/package.json +1 -1
@@ -68,10 +68,9 @@ class PlaybackServiceCallbackSearchTest {
68
68
  override fun commandHandler() = null
69
69
  override fun mediaButtonDispatch() = null
70
70
  override fun playerCommandsFor(
71
- capability: SkipCapability,
72
- hasMediaItems: Boolean
71
+ capability: SkipCapability
73
72
  ): Player.Commands =
74
- service.playerCommandsFor(capability, hasMediaItems)
73
+ service.playerCommandsFor(capability)
75
74
  override fun onSearchQueryReceived(query: String) {
76
75
  received.add(query)
77
76
  }
@@ -227,8 +227,7 @@ class PlaybackServiceCallbackTest {
227
227
  @Test
228
228
  fun `playerCommandsFor strips both skips when capability disallows both`() {
229
229
  val commands = service.playerCommandsFor(
230
- SkipCapability(canSkipPrevious = false, canSkipNext = false),
231
- hasMediaItems = true
230
+ SkipCapability(canSkipPrevious = false, canSkipNext = false)
232
231
  )
233
232
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
234
233
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -243,23 +242,21 @@ class PlaybackServiceCallbackTest {
243
242
  }
244
243
 
245
244
  @Test
246
- fun `playerCommandsFor strips PLAY-related commands when queue is empty`() {
247
- // Empty queue action mask must carry no ACTION_PLAY bits so
248
- // AA's dashboard tap routes to Library rather than Now Playing.
245
+ fun `playerCommandsFor keeps transport commands when queue is empty`() {
246
+ // Transport commands stay available so the API 33+ System UI keeps the
247
+ // play/pause button + the draggable seek scrubber; only the skip pair
248
+ // greys out with the queue boundary.
249
249
  val commands = service.playerCommandsFor(
250
- SkipCapability(canSkipPrevious = false, canSkipNext = false),
251
- hasMediaItems = false
250
+ SkipCapability(canSkipPrevious = false, canSkipNext = false)
252
251
  )
253
- assertFalse(
254
- "PLAY_PAUSE drives the legacy ACTION_PLAY bit; must be absent on empty queue",
252
+ assertTrue(
253
+ "play/pause is retained for the System UI play/pause button",
255
254
  commands.contains(Player.COMMAND_PLAY_PAUSE)
256
255
  )
257
- assertFalse(commands.contains(Player.COMMAND_PREPARE))
258
- assertFalse(commands.contains(Player.COMMAND_STOP))
259
- assertFalse(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
260
- assertFalse(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
261
- assertFalse(commands.contains(Player.COMMAND_SET_REPEAT_MODE))
262
- assertFalse(commands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
256
+ assertTrue(commands.contains(Player.COMMAND_PREPARE))
257
+ assertTrue(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
258
+ assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
259
+ assertTrue(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
263
260
  }
264
261
 
265
262
  @Test
@@ -342,7 +339,7 @@ class PlaybackServiceCallbackTest {
342
339
  // The handler's value is what `PlaybackCallback.onConnect` reads,
343
340
  // so a fresh `onConnect` against any session sharing this handler
344
341
  // sees the post-flip command set.
345
- val playerCommands = service.playerCommandsFor(mid, hasMediaItems = true)
342
+ val playerCommands = service.playerCommandsFor(mid)
346
343
  assertTrue(playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
347
344
  assertFalse(playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
348
345
  } finally {
@@ -375,6 +372,76 @@ class PlaybackServiceCallbackTest {
375
372
  }
376
373
  }
377
374
 
375
+ @Test
376
+ fun `TrackPlayer onSeekToMediaItemCommand jumps to the tapped index and stamps reasons`() {
377
+ val player = newConfiguredPlayer()
378
+ try {
379
+ player.tracks = listOf(
380
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
381
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
382
+ TrackItem("c", "https://example.com/c.m4a", null, null, null, null, null, null),
383
+ )
384
+ player.currentTrackIndex = 0
385
+
386
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(2)
387
+
388
+ assertTrue(
389
+ "an Android Auto queue-row tap is queue-aware and reports interception",
390
+ intercepted
391
+ )
392
+ assertEquals(2, player.currentTrackIndex)
393
+ assertEquals(StateChangeReason.SYSTEM, player.pendingStateChangeReason)
394
+ assertEquals(
395
+ TrackChangeReason.USER_SKIP_TO_INDEX, player.pendingTrackChangeReason
396
+ )
397
+ } finally {
398
+ tearDownPlayer(player)
399
+ }
400
+ }
401
+
402
+ @Test
403
+ fun `TrackPlayer onSeekToMediaItemCommand declines an out-of-range index and leaves the current index`() {
404
+ val player = newConfiguredPlayer()
405
+ try {
406
+ player.tracks = listOf(
407
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
408
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
409
+ )
410
+ player.currentTrackIndex = 1
411
+
412
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(5)
413
+
414
+ assertFalse(
415
+ "an out-of-range tap is declined so the default routing no-ops it",
416
+ intercepted
417
+ )
418
+ assertEquals(1, player.currentTrackIndex)
419
+ } finally {
420
+ tearDownPlayer(player)
421
+ }
422
+ }
423
+
424
+ @Test
425
+ fun `TrackPlayer onSeekToMediaItemCommand on the current index is handled and keeps the index`() {
426
+ val player = newConfiguredPlayer()
427
+ try {
428
+ player.tracks = listOf(
429
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
430
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
431
+ )
432
+ player.currentTrackIndex = 1
433
+
434
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(1)
435
+
436
+ // Tapping the playing row behaves like skipToIndex(current): handled,
437
+ // index unchanged, the engine restarts the track from the top.
438
+ assertTrue(intercepted)
439
+ assertEquals(1, player.currentTrackIndex)
440
+ } finally {
441
+ tearDownPlayer(player)
442
+ }
443
+ }
444
+
378
445
  // --- Voice / browse-leaf routing through onAddMediaItems / onSetMediaItems
379
446
 
380
447
  @Test
@@ -759,6 +826,8 @@ class PlaybackServiceCallbackTest {
759
826
  return skipPreviousHandled
760
827
  }
761
828
 
829
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
830
+
762
831
  override fun getCachedSkipCapability(): SkipCapability = capability
763
832
 
764
833
  // This suite covers the controller-driven callback routing only.
@@ -804,10 +873,9 @@ class PlaybackServiceCallbackTest {
804
873
  override fun mediaButtonDispatch(): MediaButtonDispatch? = null
805
874
 
806
875
  override fun playerCommandsFor(
807
- capability: SkipCapability,
808
- hasMediaItems: Boolean
876
+ capability: SkipCapability
809
877
  ): Player.Commands =
810
- service.playerCommandsFor(capability, hasMediaItems)
878
+ service.playerCommandsFor(capability)
811
879
 
812
880
  override fun onPlayFromSearchRequest(
813
881
  request: MediaSearchRequest
@@ -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
 
@@ -65,7 +65,6 @@ class PlayerCommandsForTest {
65
65
  fun `commands include every skip pair when capability allows both`() {
66
66
  val commands = service.playerCommandsFor(
67
67
  SkipCapability(canSkipPrevious = true, canSkipNext = true),
68
- hasMediaItems = true,
69
68
  )
70
69
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
71
70
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -78,7 +77,6 @@ class PlayerCommandsForTest {
78
77
  fun `commands omit only the next pair when capability disallows skip-next`() {
79
78
  val commands = service.playerCommandsFor(
80
79
  SkipCapability(canSkipPrevious = true, canSkipNext = false),
81
- hasMediaItems = true,
82
80
  )
83
81
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
84
82
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -91,7 +89,6 @@ class PlayerCommandsForTest {
91
89
  fun `commands omit only the previous pair when capability disallows skip-previous`() {
92
90
  val commands = service.playerCommandsFor(
93
91
  SkipCapability(canSkipPrevious = false, canSkipNext = true),
94
- hasMediaItems = true,
95
92
  )
96
93
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
97
94
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -104,7 +101,6 @@ class PlayerCommandsForTest {
104
101
  fun `commands omit both skip pairs when capability disallows both`() {
105
102
  val commands = service.playerCommandsFor(
106
103
  SkipCapability(canSkipPrevious = false, canSkipNext = false),
107
- hasMediaItems = true,
108
104
  )
109
105
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
110
106
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -119,7 +115,6 @@ class PlayerCommandsForTest {
119
115
  fun `repeat OFF at index 0 of a two-track queue exposes only skip-next`() {
120
116
  val commands = service.playerCommandsFor(
121
117
  capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 0),
122
- hasMediaItems = true,
123
118
  )
124
119
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
125
120
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
@@ -129,7 +124,6 @@ class PlayerCommandsForTest {
129
124
  fun `repeat OFF at the last index of a two-track queue exposes only skip-previous`() {
130
125
  val commands = service.playerCommandsFor(
131
126
  capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 1),
132
- hasMediaItems = true,
133
127
  )
134
128
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
135
129
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
@@ -139,7 +133,6 @@ class PlayerCommandsForTest {
139
133
  fun `repeat OFF on a single-track queue exposes neither skip command`() {
140
134
  val commands = service.playerCommandsFor(
141
135
  capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 1, current = 0),
142
- hasMediaItems = true,
143
136
  )
144
137
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
145
138
  assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
@@ -149,15 +142,12 @@ class PlayerCommandsForTest {
149
142
  fun `repeat QUEUE wraps so both skip commands stay enabled at every queue position`() {
150
143
  val first = service.playerCommandsFor(
151
144
  capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 0),
152
- hasMediaItems = true,
153
145
  )
154
146
  val mid = service.playerCommandsFor(
155
147
  capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 1),
156
- hasMediaItems = true,
157
148
  )
158
149
  val last = service.playerCommandsFor(
159
150
  capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 2),
160
- hasMediaItems = true,
161
151
  )
162
152
  for (commands in listOf(first, mid, last)) {
163
153
  assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
@@ -170,7 +160,6 @@ class PlayerCommandsForTest {
170
160
  // First track of 2: next available, no previous (no wrap).
171
161
  val atFirst = service.playerCommandsFor(
172
162
  capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 0),
173
- hasMediaItems = true,
174
163
  )
175
164
  assertTrue(atFirst.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
176
165
  assertFalse(atFirst.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
@@ -178,31 +167,33 @@ class PlayerCommandsForTest {
178
167
  // Last track of 2: previous available, no next (no wrap).
179
168
  val atLast = service.playerCommandsFor(
180
169
  capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 1),
181
- hasMediaItems = true,
182
170
  )
183
171
  assertFalse(atLast.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
184
172
  assertTrue(atLast.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
185
173
  }
186
174
 
187
175
  @Test
188
- fun `empty queue exposes neither skip command regardless of repeat mode`() {
176
+ fun `empty queue disables skip but keeps transport commands for the System UI`() {
189
177
  for (mode in QueueSkipRepeatMode.entries) {
190
178
  val commands = service.playerCommandsFor(
191
179
  capabilityFor(repeat = mode, count = 0, current = -1),
192
- hasMediaItems = false,
193
180
  )
194
181
  assertFalse(
195
- "skip-next is always disabled with no tracks (repeat=$mode)",
182
+ "skip-next is disabled with no tracks (repeat=$mode)",
196
183
  commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM),
197
184
  )
198
185
  assertFalse(
199
- "skip-previous is always disabled with no tracks (repeat=$mode)",
186
+ "skip-previous is disabled with no tracks (repeat=$mode)",
200
187
  commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
201
188
  )
202
- assertFalse(
203
- "PLAY_PAUSE is stripped on empty queue so AA lands on Library (repeat=$mode)",
189
+ assertTrue(
190
+ "play/pause is retained so the API 33+ System UI keeps the button (repeat=$mode)",
204
191
  commands.contains(Player.COMMAND_PLAY_PAUSE),
205
192
  )
193
+ assertTrue(
194
+ "in-item seek is retained so the seek scrubber stays draggable (repeat=$mode)",
195
+ commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM),
196
+ )
206
197
  }
207
198
  }
208
199
 
@@ -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
 
@@ -187,6 +187,7 @@ private class FakePlaybackEngineWithSinks : PlaybackEngine {
187
187
  override val bufferedPositionMs: Long get() = error("not used in test")
188
188
  override val isPlaying: Boolean get() = error("not used in test")
189
189
  override val currentMediaItem: androidx.media3.common.MediaItem? get() = error("not used in test")
190
+ override val isCrossfadeActive: Boolean get() = false
190
191
  override val allMediaItems: List<androidx.media3.common.MediaItem> get() = error("not used in test")
191
192
  override fun currentAudioSessionIds(): IntArray = IntArray(0)
192
193
  override fun addAudioSessionIdListener(listener: (IntArray) -> Unit): () -> Unit = { /* noop */ }
@@ -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
+ }