@rntp/player 5.0.0-beta.3 → 5.0.0-beta.5

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 (61) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  14. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
  17. package/ios/TrackPlayer.swift +46 -101
  18. package/ios/TrackPlayerBridge.mm +2 -0
  19. package/ios/player/AVPlayerEngine.swift +46 -32
  20. package/ios/player/AudioCache.swift +34 -0
  21. package/ios/player/AudioPlayer.swift +36 -21
  22. package/ios/player/CacheProxyServer.swift +429 -0
  23. package/ios/player/DownloadCoordinator.swift +242 -0
  24. package/ios/player/Preloader.swift +21 -90
  25. package/ios/player/SleepTimerController.swift +147 -0
  26. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  27. package/ios/tests/AudioPlayerTests.swift +6 -0
  28. package/ios/tests/CacheProxyServerTests.swift +403 -0
  29. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  30. package/ios/tests/LocalAudioServer.swift +171 -0
  31. package/ios/tests/MockPlayerEngine.swift +1 -0
  32. package/ios/tests/QueueManagerTests.swift +6 -0
  33. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  34. package/ios/tests/SleepTimerTests.swift +70 -0
  35. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  36. package/lib/commonjs/audio.js +39 -4
  37. package/lib/commonjs/audio.js.map +1 -1
  38. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  39. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  40. package/lib/module/NativeTrackPlayer.js.map +1 -1
  41. package/lib/module/audio.js +37 -4
  42. package/lib/module/audio.js.map +1 -1
  43. package/lib/module/interfaces/PlayerConfig.js +1 -1
  44. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  45. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  46. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  47. package/lib/typescript/src/audio.d.ts +16 -4
  48. package/lib/typescript/src/audio.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
  50. package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  52. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  53. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  54. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/NativeTrackPlayer.ts +4 -0
  57. package/src/audio.ts +37 -4
  58. package/src/interfaces/BrowseTree.ts +40 -5
  59. package/src/interfaces/MediaItem.ts +4 -1
  60. package/src/interfaces/PlayerConfig.ts +22 -3
  61. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -0,0 +1,380 @@
1
+ package com.doublesymmetry.trackplayer.models
2
+
3
+ import androidx.media3.common.MediaItem
4
+ import androidx.media3.common.MediaMetadata
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.JavaOnlyMap
7
+ import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.ReadableMapKeySetIterator
9
+ import com.facebook.react.bridge.ReadableType
10
+ import io.mockk.every
11
+ import io.mockk.mockk
12
+ import io.mockk.mockkStatic
13
+ import io.mockk.unmockkStatic
14
+ import org.junit.After
15
+ import org.junit.Before
16
+ import org.junit.Test
17
+ import org.junit.Assert.*
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.RobolectricTestRunner
20
+
21
+ @RunWith(RobolectricTestRunner::class)
22
+ class TrackPlayerMediaItemTest {
23
+
24
+ @Before
25
+ fun setUp() {
26
+ // Arguments.createMap() and createArray() call into native code; replace with JavaOnlyMap.
27
+ mockkStatic(Arguments::class)
28
+ every { Arguments.createMap() } answers { JavaOnlyMap() }
29
+ }
30
+
31
+ @After
32
+ fun tearDown() {
33
+ unmockkStatic(Arguments::class)
34
+ }
35
+
36
+ // ---- fromReadableMap ----
37
+
38
+ @Test
39
+ fun `fromReadableMap parses basic fields`() {
40
+ val map = mockk<ReadableMap>(relaxed = true) {
41
+ every { hasKey("url") } returns true
42
+ every { getType("url") } returns mockk { every { name } returns "String" }
43
+ every { getString("url") } returns "https://example.com/track.mp3"
44
+ every { hasKey("mediaId") } returns true
45
+ every { getString("mediaId") } returns "track-123"
46
+ every { hasKey("title") } returns true
47
+ every { getString("title") } returns "My Track"
48
+ every { hasKey("artist") } returns true
49
+ every { getString("artist") } returns "My Artist"
50
+ every { hasKey("albumTitle") } returns true
51
+ every { getString("albumTitle") } returns "My Album"
52
+ every { hasKey("artworkUrl") } returns true
53
+ every { getString("artworkUrl") } returns "https://example.com/art.jpg"
54
+ every { hasKey("duration") } returns true
55
+ every { getDouble("duration") } returns 180.5
56
+ every { hasKey("isLive") } returns true
57
+ every { getBoolean("isLive") } returns false
58
+ every { hasKey("mimeType") } returns false
59
+ }
60
+
61
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
62
+
63
+ assertEquals("track-123", item.mediaId)
64
+ assertEquals("https://example.com/track.mp3", item.url)
65
+ assertEquals("My Track", item.title)
66
+ assertEquals("My Artist", item.artist)
67
+ assertEquals("My Album", item.albumTitle)
68
+ assertEquals("https://example.com/art.jpg", item.artworkUrl)
69
+ assertEquals(180.5, item.duration!!, 0.001)
70
+ assertEquals(false, item.isLive)
71
+ assertNull(item.mimeType)
72
+ assertNull(item.headers)
73
+ }
74
+
75
+ @Test
76
+ fun `fromReadableMap with missing optional fields uses null`() {
77
+ val map = mockk<ReadableMap>(relaxed = true) {
78
+ every { hasKey("url") } returns true
79
+ every { getType("url") } returns mockk { every { name } returns "String" }
80
+ every { getString("url") } returns "https://example.com/track.mp3"
81
+ every { hasKey("mediaId") } returns false
82
+ every { hasKey("title") } returns false
83
+ every { hasKey("artist") } returns false
84
+ every { hasKey("albumTitle") } returns false
85
+ every { hasKey("artworkUrl") } returns false
86
+ every { hasKey("duration") } returns false
87
+ every { hasKey("isLive") } returns false
88
+ every { hasKey("mimeType") } returns false
89
+ }
90
+
91
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
92
+
93
+ assertNull(item.mediaId)
94
+ assertEquals("https://example.com/track.mp3", item.url)
95
+ assertNull(item.title)
96
+ assertNull(item.artist)
97
+ assertNull(item.albumTitle)
98
+ assertNull(item.artworkUrl)
99
+ assertNull(item.duration)
100
+ assertNull(item.isLive)
101
+ assertNull(item.headers)
102
+ }
103
+
104
+ @Test
105
+ fun `fromReadableMap with url as object parses uri and headers`() {
106
+ val headersIterator = mockk<ReadableMapKeySetIterator> {
107
+ every { hasNextKey() } returnsMany listOf(true, true, false)
108
+ every { nextKey() } returnsMany listOf("Authorization", "X-Custom")
109
+ }
110
+ val headersMap = mockk<ReadableMap>(relaxed = true) {
111
+ every { keySetIterator() } returns headersIterator
112
+ every { getString("Authorization") } returns "Bearer token123"
113
+ every { getString("X-Custom") } returns "custom-value"
114
+ }
115
+ val urlMap = mockk<ReadableMap>(relaxed = true) {
116
+ every { getString("uri") } returns "https://example.com/secure.mp3"
117
+ every { getString("url") } returns null
118
+ every { getMap("headers") } returns headersMap
119
+ }
120
+
121
+ val map = mockk<ReadableMap>(relaxed = true) {
122
+ every { hasKey("url") } returns true
123
+ every { getType("url") } returns mockk { every { name } returns "Map" }
124
+ every { getMap("url") } returns urlMap
125
+ every { hasKey("mediaId") } returns false
126
+ every { hasKey("title") } returns false
127
+ every { hasKey("artist") } returns false
128
+ every { hasKey("albumTitle") } returns false
129
+ every { hasKey("artworkUrl") } returns false
130
+ every { hasKey("duration") } returns false
131
+ every { hasKey("isLive") } returns false
132
+ every { hasKey("mimeType") } returns false
133
+ }
134
+
135
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
136
+
137
+ assertEquals("https://example.com/secure.mp3", item.url)
138
+ assertNotNull(item.headers)
139
+ assertEquals("Bearer token123", item.headers!!["Authorization"])
140
+ assertEquals("custom-value", item.headers!!["X-Custom"])
141
+ }
142
+
143
+ @Test
144
+ fun `fromReadableMap with url object falls back to url key when uri absent`() {
145
+ val urlMap = mockk<ReadableMap>(relaxed = true) {
146
+ every { getString("uri") } returns null
147
+ every { getString("url") } returns "https://example.com/fallback.mp3"
148
+ every { getMap("headers") } returns null
149
+ }
150
+
151
+ val map = mockk<ReadableMap>(relaxed = true) {
152
+ every { hasKey("url") } returns true
153
+ every { getType("url") } returns mockk { every { name } returns "Map" }
154
+ every { getMap("url") } returns urlMap
155
+ every { hasKey("mediaId") } returns false
156
+ every { hasKey("title") } returns false
157
+ every { hasKey("artist") } returns false
158
+ every { hasKey("albumTitle") } returns false
159
+ every { hasKey("artworkUrl") } returns false
160
+ every { hasKey("duration") } returns false
161
+ every { hasKey("isLive") } returns false
162
+ every { hasKey("mimeType") } returns false
163
+ }
164
+
165
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
166
+ assertEquals("https://example.com/fallback.mp3", item.url)
167
+ }
168
+
169
+ // ---- asMediaItem ----
170
+
171
+ @Test
172
+ fun `asMediaItem produces correct Media3 MediaItem`() {
173
+ val item = TrackPlayerMediaItem(
174
+ mediaId = "track-abc",
175
+ url = "https://example.com/track.mp3",
176
+ title = "Test Track",
177
+ artist = "Test Artist",
178
+ albumTitle = "Test Album",
179
+ artworkUrl = "https://example.com/art.jpg",
180
+ )
181
+
182
+ val mediaItem = item.asMediaItem()
183
+
184
+ assertEquals("track-abc", mediaItem.mediaId)
185
+ assertNotNull(mediaItem.localConfiguration)
186
+ assertEquals("https://example.com/track.mp3", mediaItem.localConfiguration!!.uri.toString())
187
+ assertEquals("Test Track", mediaItem.mediaMetadata.title.toString())
188
+ assertEquals("Test Artist", mediaItem.mediaMetadata.artist.toString())
189
+ assertEquals("Test Album", mediaItem.mediaMetadata.albumTitle.toString())
190
+ assertEquals("https://example.com/art.jpg", mediaItem.mediaMetadata.artworkUri.toString())
191
+ }
192
+
193
+ @Test
194
+ fun `asMediaItem uses url as mediaId when mediaId is null`() {
195
+ val item = TrackPlayerMediaItem(
196
+ mediaId = null,
197
+ url = "https://example.com/track.mp3",
198
+ )
199
+
200
+ val mediaItem = item.asMediaItem()
201
+ assertEquals("https://example.com/track.mp3", mediaItem.mediaId)
202
+ }
203
+
204
+ @Test
205
+ fun `asMediaItem stores duration and isLive in extras`() {
206
+ val item = TrackPlayerMediaItem(
207
+ url = "https://example.com/track.mp3",
208
+ duration = 240.0,
209
+ isLive = true,
210
+ )
211
+
212
+ val mediaItem = item.asMediaItem()
213
+ val extras = mediaItem.mediaMetadata.extras
214
+
215
+ assertNotNull(extras)
216
+ assertEquals(240.0, extras!!.getDouble("duration"), 0.001)
217
+ assertTrue(extras.getBoolean("isLive"))
218
+ assertNotNull(mediaItem.liveConfiguration)
219
+ }
220
+
221
+ @Test
222
+ fun `asMediaItem with headers stores headers in request extras`() {
223
+ val item = TrackPlayerMediaItem(
224
+ url = "https://example.com/track.mp3",
225
+ headers = mapOf("Authorization" to "Bearer xyz", "Accept" to "audio/*"),
226
+ )
227
+
228
+ val mediaItem = item.asMediaItem()
229
+ val requestExtras = mediaItem.requestMetadata.extras
230
+ assertNotNull(requestExtras)
231
+ val headerBundle = requestExtras!!.getBundle("headers")
232
+ assertNotNull(headerBundle)
233
+ assertEquals("Bearer xyz", headerBundle!!.getString("Authorization"))
234
+ assertEquals("audio/*", headerBundle.getString("Accept"))
235
+ }
236
+
237
+ @Test
238
+ fun `asMediaItem sets mediaUri in requestMetadata for http urls`() {
239
+ val item = TrackPlayerMediaItem(
240
+ url = "https://example.com/track.mp3",
241
+ )
242
+
243
+ val mediaItem = item.asMediaItem()
244
+ // HTTP URIs should be set as mediaUri for Cast support
245
+ assertNotNull(mediaItem.requestMetadata.mediaUri)
246
+ assertEquals("https://example.com/track.mp3", mediaItem.requestMetadata.mediaUri.toString())
247
+ }
248
+
249
+ @Test
250
+ fun `asMediaItem does not set mediaUri for file urls`() {
251
+ val item = TrackPlayerMediaItem(
252
+ url = "file:///storage/music/local.mp3",
253
+ )
254
+
255
+ val mediaItem = item.asMediaItem()
256
+ // Local file URIs should not be set as mediaUri (inaccessible from Chromecast)
257
+ assertNull(mediaItem.requestMetadata.mediaUri)
258
+ }
259
+
260
+ @Test
261
+ fun `asMediaItem sets mimeType when provided`() {
262
+ val item = TrackPlayerMediaItem(
263
+ url = "https://example.com/track.mp3",
264
+ mimeType = "audio/mpeg",
265
+ )
266
+
267
+ val mediaItem = item.asMediaItem()
268
+ assertEquals("audio/mpeg", mediaItem.localConfiguration?.mimeType)
269
+ }
270
+
271
+ // ---- toWritableMap ----
272
+
273
+ @Test
274
+ fun `toWritableMap includes all fields`() {
275
+ val item = TrackPlayerMediaItem(
276
+ mediaId = "t1",
277
+ url = "https://example.com/track.mp3",
278
+ title = "Track",
279
+ artist = "Artist",
280
+ albumTitle = "Album",
281
+ artworkUrl = "https://example.com/art.jpg",
282
+ duration = 120.0,
283
+ isLive = false,
284
+ )
285
+
286
+ val map = item.toWritableMap()
287
+ // WritableMap from Arguments.createMap() — verify no exception thrown and key existence
288
+ // (JavaOnlyMap in Robolectric supports getString/getDouble/getBoolean)
289
+ assertEquals("t1", map.getString("mediaId"))
290
+ assertEquals("https://example.com/track.mp3", map.getString("url"))
291
+ assertEquals("Track", map.getString("title"))
292
+ assertEquals("Artist", map.getString("artist"))
293
+ assertEquals("Album", map.getString("albumTitle"))
294
+ assertEquals("https://example.com/art.jpg", map.getString("artworkUrl"))
295
+ assertEquals(120.0, map.getDouble("duration"), 0.001)
296
+ assertFalse(map.getBoolean("isLive"))
297
+ }
298
+
299
+ @Test
300
+ fun `toWritableMap uses url as mediaId when mediaId is null`() {
301
+ val item = TrackPlayerMediaItem(
302
+ mediaId = null,
303
+ url = "https://example.com/track.mp3",
304
+ )
305
+
306
+ val map = item.toWritableMap()
307
+ assertEquals("https://example.com/track.mp3", map.getString("mediaId"))
308
+ }
309
+
310
+ @Test
311
+ fun `toWritableMap omits null optional fields`() {
312
+ val item = TrackPlayerMediaItem(
313
+ url = "https://example.com/track.mp3",
314
+ )
315
+
316
+ val map = item.toWritableMap()
317
+ assertFalse(map.hasKey("title"))
318
+ assertFalse(map.hasKey("artist"))
319
+ assertFalse(map.hasKey("albumTitle"))
320
+ assertFalse(map.hasKey("artworkUrl"))
321
+ assertFalse(map.hasKey("duration"))
322
+ assertFalse(map.hasKey("isLive"))
323
+ }
324
+
325
+ // ---- fromMediaItem round-trip ----
326
+
327
+ @Test
328
+ fun `fromMediaItem round-trips correctly`() {
329
+ val original = TrackPlayerMediaItem(
330
+ mediaId = "round-trip-id",
331
+ url = "https://example.com/track.mp3",
332
+ title = "Round Trip",
333
+ artist = "Artist",
334
+ albumTitle = "Album",
335
+ artworkUrl = "https://example.com/art.jpg",
336
+ duration = 90.0,
337
+ )
338
+
339
+ val mediaItem = original.asMediaItem()
340
+ val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem)
341
+
342
+ assertEquals("round-trip-id", restored.mediaId)
343
+ assertEquals("https://example.com/track.mp3", restored.url)
344
+ assertEquals("Round Trip", restored.title)
345
+ assertEquals("Artist", restored.artist)
346
+ assertEquals("Album", restored.albumTitle)
347
+ assertEquals("https://example.com/art.jpg", restored.artworkUrl)
348
+ assertEquals(90.0, restored.duration!!, 0.001)
349
+ assertNull(restored.isLive) // not set so extras won't contain "isLive"
350
+ }
351
+
352
+ @Test
353
+ fun `fromMediaItem preserves isLive true`() {
354
+ val original = TrackPlayerMediaItem(
355
+ url = "https://example.com/live.mp3",
356
+ isLive = true,
357
+ )
358
+
359
+ val mediaItem = original.asMediaItem()
360
+ val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem)
361
+
362
+ assertEquals(true, restored.isLive)
363
+ }
364
+
365
+ @Test
366
+ fun `fromMediaItem with no localConfiguration falls back to mediaId as url`() {
367
+ // Build a Media3 MediaItem without a URI (e.g., cast-only item)
368
+ val mediaItem = MediaItem.Builder()
369
+ .setMediaId("cast-item-id")
370
+ .setMediaMetadata(MediaMetadata.Builder().setTitle("Cast Track").build())
371
+ .build()
372
+
373
+ val item = TrackPlayerMediaItem.fromMediaItem(mediaItem)
374
+
375
+ assertEquals("cast-item-id", item.mediaId)
376
+ // Falls back to mediaId when no localConfiguration
377
+ assertEquals("cast-item-id", item.url)
378
+ assertEquals("Cast Track", item.title)
379
+ }
380
+ }
@@ -115,7 +115,7 @@ class RNTPCarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate
115
115
  let truncated = maxItems > 0 ? Array(items.prefix(maxItems)) : items
116
116
 
117
117
  let listItems: [CPListItem] = truncated.enumerated().map { (itemIndex, item) in
118
- buildListItem(item: item, categoryIndex: categoryIndex, itemIndex: itemIndex)
118
+ buildListItem(item: item, parentItems: items, itemIndex: itemIndex)
119
119
  }
120
120
 
121
121
  let section = CPListSection(items: listItems)
@@ -151,15 +151,26 @@ class RNTPCarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate
151
151
 
152
152
  // MARK: - List Item Builder
153
153
 
154
- private func buildListItem(item: [String: Any], categoryIndex: Int, itemIndex: Int) -> CPListItem {
154
+ private func buildListItem(item: [String: Any], parentItems: [[String: Any]], itemIndex: Int) -> CPListItem {
155
155
  let title = item["title"] as? String ?? "Untitled"
156
156
  let artist = item["artist"] as? String
157
157
  let listItem = CPListItem(text: title, detailText: artist)
158
158
 
159
- listItem.userInfo = ["categoryIndex": categoryIndex, "itemIndex": itemIndex]
160
- listItem.handler = { [weak self] _, completion in
161
- self?.handleItemSelected(categoryIndex: categoryIndex, itemIndex: itemIndex)
162
- completion()
159
+ let hasUrl = item["url"] != nil
160
+ let children = item["children"] as? [[String: Any]]
161
+ let isBrowsable = children != nil && !hasUrl
162
+
163
+ if isBrowsable {
164
+ listItem.accessoryType = .disclosureIndicator
165
+ listItem.handler = { [weak self] _, completion in
166
+ self?.handleBrowsableSelected(item: item)
167
+ completion()
168
+ }
169
+ } else {
170
+ listItem.handler = { [weak self] _, completion in
171
+ self?.handlePlayableSelected(parentItems: parentItems, itemIndex: itemIndex)
172
+ completion()
173
+ }
163
174
  }
164
175
 
165
176
  // Track by mediaId for now-playing state updates
@@ -184,6 +195,22 @@ class RNTPCarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate
184
195
  return listItem
185
196
  }
186
197
 
198
+ private func handleBrowsableSelected(item: [String: Any]) {
199
+ let title = item["title"] as? String ?? "Untitled"
200
+ let children = item["children"] as? [[String: Any]] ?? []
201
+
202
+ let maxItems = CPListTemplate.maximumItemCount
203
+ let truncated = maxItems > 0 ? Array(children.prefix(maxItems)) : children
204
+
205
+ let listItems = truncated.enumerated().map { (idx, child) in
206
+ buildListItem(item: child, parentItems: children, itemIndex: idx)
207
+ }
208
+
209
+ let section = CPListSection(items: listItems)
210
+ let template = CPListTemplate(title: title, sections: [section])
211
+ interfaceController?.pushTemplate(template, animated: true, completion: nil)
212
+ }
213
+
187
214
  // MARK: - Now Playing State
188
215
 
189
216
  private func updateNowPlayingState() {
@@ -198,20 +225,22 @@ class RNTPCarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate
198
225
 
199
226
  // MARK: - Playback
200
227
 
201
- private func handleItemSelected(categoryIndex: Int, itemIndex: Int) {
228
+ private func handlePlayableSelected(parentItems: [[String: Any]], itemIndex: Int) {
202
229
  guard let player = BrowseTreeStore.shared.player else { return }
203
- let categories = BrowseTreeStore.shared.categories
204
- guard categoryIndex < categories.count,
205
- let items = categories[categoryIndex]["items"] as? [[String: Any]] else { return }
206
230
 
207
- let mediaItems = items.compactMap { MediaItem(data: $0) }
231
+ // Collect only playable siblings (items with a url)
232
+ let playableItems = parentItems.filter { $0["url"] != nil }
233
+ let mediaItems = playableItems.compactMap { MediaItem(data: $0) }
208
234
  guard !mediaItems.isEmpty else { return }
209
235
 
236
+ // Find the index of the selected item among playable siblings
237
+ let selectedMediaId = parentItems[itemIndex]["mediaId"] as? String
238
+ let playableIndex = playableItems.firstIndex { ($0["mediaId"] as? String) == selectedMediaId } ?? 0
239
+
210
240
  player.clear()
211
241
  player.add(items: mediaItems)
212
- let idx = min(itemIndex, mediaItems.count - 1)
213
- if idx > 0 {
214
- player.skipTo(index: idx)
242
+ if playableIndex > 0 {
243
+ player.skipTo(index: playableIndex)
215
244
  }
216
245
  player.play()
217
246
  }