@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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
- package/ios/TrackPlayer.swift +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +39 -4
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +37 -4
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +16 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
- package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +37 -4
- package/src/interfaces/BrowseTree.ts +40 -5
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sdk=33
|
|
@@ -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,
|
|
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],
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
player.skipTo(index: idx)
|
|
242
|
+
if playableIndex > 0 {
|
|
243
|
+
player.skipTo(index: playableIndex)
|
|
215
244
|
}
|
|
216
245
|
player.play()
|
|
217
246
|
}
|