react-native-queue-player 1.0.4 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
- package/android/src/main/res/values/strings.xml +0 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/ios/CarPlayCoordinator.swift +10 -0
- package/ios/CarPlayQueueBuilder.swift +82 -0
- package/ios/CarPlayQueueProvider.swift +73 -0
- package/ios/CarPlaySceneDelegate.swift +79 -7
- package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
- package/ios/Tests/FFTProcessorTests.swift +9 -7
- package/ios/TrackPlayer.swift +47 -0
- package/package.json +1 -1
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt
CHANGED
|
@@ -68,10 +68,9 @@ class PlaybackServiceCallbackSearchTest {
|
|
|
68
68
|
override fun commandHandler() = null
|
|
69
69
|
override fun mediaButtonDispatch() = null
|
|
70
70
|
override fun playerCommandsFor(
|
|
71
|
-
capability: SkipCapability
|
|
72
|
-
hasMediaItems: Boolean
|
|
71
|
+
capability: SkipCapability
|
|
73
72
|
): Player.Commands =
|
|
74
|
-
service.playerCommandsFor(capability
|
|
73
|
+
service.playerCommandsFor(capability)
|
|
75
74
|
override fun onSearchQueryReceived(query: String) {
|
|
76
75
|
received.add(query)
|
|
77
76
|
}
|
|
@@ -227,8 +227,7 @@ class PlaybackServiceCallbackTest {
|
|
|
227
227
|
@Test
|
|
228
228
|
fun `playerCommandsFor strips both skips when capability disallows both`() {
|
|
229
229
|
val commands = service.playerCommandsFor(
|
|
230
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
231
|
-
hasMediaItems = true
|
|
230
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
232
231
|
)
|
|
233
232
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
234
233
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -243,23 +242,21 @@ class PlaybackServiceCallbackTest {
|
|
|
243
242
|
}
|
|
244
243
|
|
|
245
244
|
@Test
|
|
246
|
-
fun `playerCommandsFor
|
|
247
|
-
//
|
|
248
|
-
//
|
|
245
|
+
fun `playerCommandsFor keeps transport commands when queue is empty`() {
|
|
246
|
+
// Transport commands stay available so the API 33+ System UI keeps the
|
|
247
|
+
// play/pause button + the draggable seek scrubber; only the skip pair
|
|
248
|
+
// greys out with the queue boundary.
|
|
249
249
|
val commands = service.playerCommandsFor(
|
|
250
|
-
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
251
|
-
hasMediaItems = false
|
|
250
|
+
SkipCapability(canSkipPrevious = false, canSkipNext = false)
|
|
252
251
|
)
|
|
253
|
-
|
|
254
|
-
"
|
|
252
|
+
assertTrue(
|
|
253
|
+
"play/pause is retained for the System UI play/pause button",
|
|
255
254
|
commands.contains(Player.COMMAND_PLAY_PAUSE)
|
|
256
255
|
)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
assertFalse(commands.contains(Player.COMMAND_SET_REPEAT_MODE))
|
|
262
|
-
assertFalse(commands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
|
|
256
|
+
assertTrue(commands.contains(Player.COMMAND_PREPARE))
|
|
257
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_DEFAULT_POSITION))
|
|
258
|
+
assertTrue(commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
|
|
259
|
+
assertTrue(commands.contains(Player.COMMAND_SET_SPEED_AND_PITCH))
|
|
263
260
|
}
|
|
264
261
|
|
|
265
262
|
@Test
|
|
@@ -342,7 +339,7 @@ class PlaybackServiceCallbackTest {
|
|
|
342
339
|
// The handler's value is what `PlaybackCallback.onConnect` reads,
|
|
343
340
|
// so a fresh `onConnect` against any session sharing this handler
|
|
344
341
|
// sees the post-flip command set.
|
|
345
|
-
val playerCommands = service.playerCommandsFor(mid
|
|
342
|
+
val playerCommands = service.playerCommandsFor(mid)
|
|
346
343
|
assertTrue(playerCommands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
347
344
|
assertFalse(playerCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
348
345
|
} finally {
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
186
|
+
"skip-previous is disabled with no tracks (repeat=$mode)",
|
|
200
187
|
commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
|
|
201
188
|
)
|
|
202
|
-
|
|
203
|
-
"
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|