@rntp/player 5.0.0 → 5.1.1

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 (60) hide show
  1. package/android/src/main/java/com/doublesymmetry/trackplayer/HeaderInjectingDataSourceFactory.kt +5 -1
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +23 -3
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +22 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +36 -0
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +10 -0
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractor.kt +98 -0
  7. package/android/src/main/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItem.kt +20 -8
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +33 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/MetadataApplierTest.kt +224 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +33 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractorTest.kt +254 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +202 -0
  13. package/ios/TrackPlayer.swift +28 -2
  14. package/ios/models/EmitEvent.swift +28 -0
  15. package/ios/models/MediaItem.swift +8 -3
  16. package/ios/tests/AVPlayerEngineIntegrationTests.swift +7 -4
  17. package/lib/commonjs/audio.js +1 -0
  18. package/lib/commonjs/audio.js.map +1 -1
  19. package/lib/commonjs/events/MediaMetadataChanged.js +2 -0
  20. package/lib/commonjs/events/MediaMetadataChanged.js.map +1 -0
  21. package/lib/commonjs/events/index.js +13 -0
  22. package/lib/commonjs/events/index.js.map +1 -1
  23. package/lib/commonjs/hooks/useActiveMediaItem.js +3 -3
  24. package/lib/commonjs/hooks/useActiveMediaItem.js.map +1 -1
  25. package/lib/commonjs/hooks/useProgress.js.map +1 -1
  26. package/lib/commonjs/interfaces/PlayerConfig.js +1 -0
  27. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  28. package/lib/module/audio.js +1 -0
  29. package/lib/module/audio.js.map +1 -1
  30. package/lib/module/events/MediaMetadataChanged.js +2 -0
  31. package/lib/module/events/MediaMetadataChanged.js.map +1 -0
  32. package/lib/module/events/index.js +2 -0
  33. package/lib/module/events/index.js.map +1 -1
  34. package/lib/module/hooks/useActiveMediaItem.js +3 -3
  35. package/lib/module/hooks/useActiveMediaItem.js.map +1 -1
  36. package/lib/module/hooks/useProgress.js.map +1 -1
  37. package/lib/module/interfaces/PlayerConfig.js +1 -0
  38. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  39. package/lib/typescript/src/audio.d.ts.map +1 -1
  40. package/lib/typescript/src/events/MediaMetadataChanged.d.ts +18 -0
  41. package/lib/typescript/src/events/MediaMetadataChanged.d.ts.map +1 -0
  42. package/lib/typescript/src/events/MetadataReceived.d.ts +15 -0
  43. package/lib/typescript/src/events/MetadataReceived.d.ts.map +1 -1
  44. package/lib/typescript/src/events/index.d.ts +4 -0
  45. package/lib/typescript/src/events/index.d.ts.map +1 -1
  46. package/lib/typescript/src/hooks/useActiveMediaItem.d.ts +2 -2
  47. package/lib/typescript/src/hooks/useProgress.d.ts.map +1 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts +15 -0
  49. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +8 -0
  51. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/audio.ts +3 -0
  54. package/src/events/MediaMetadataChanged.ts +18 -0
  55. package/src/events/MetadataReceived.ts +15 -0
  56. package/src/events/index.ts +4 -0
  57. package/src/hooks/useActiveMediaItem.ts +3 -3
  58. package/src/hooks/useProgress.ts +1 -1
  59. package/src/interfaces/MediaItem.ts +15 -0
  60. package/src/interfaces/PlayerConfig.ts +9 -0
@@ -22,6 +22,7 @@ class PlayerConfigTest {
22
22
 
23
23
  assertEquals("music", config.contentType)
24
24
  assertTrue(config.handleAudioBecomingNoisy)
25
+ assertTrue(config.autoUpdateMetadataFromStream)
25
26
  assertEquals(WakeMode.NONE, config.wakeMode)
26
27
  assertFalse(config.skipSilenceEnabled)
27
28
  assertEquals(listOf(PlayerCommand.PLAY_PAUSE), config.availableCommands)
@@ -48,6 +49,7 @@ class PlayerConfigTest {
48
49
  val config = PlayerConfig(
49
50
  contentType = "speech",
50
51
  handleAudioBecomingNoisy = false,
52
+ autoUpdateMetadataFromStream = false,
51
53
  wakeMode = WakeMode.NETWORK,
52
54
  skipSilenceEnabled = true,
53
55
  forwardInterval = 30L,
@@ -66,6 +68,7 @@ class PlayerConfigTest {
66
68
 
67
69
  assertEquals("speech", loaded.contentType)
68
70
  assertFalse(loaded.handleAudioBecomingNoisy)
71
+ assertFalse(loaded.autoUpdateMetadataFromStream)
69
72
  assertEquals(WakeMode.NETWORK, loaded.wakeMode)
70
73
  assertTrue(loaded.skipSilenceEnabled)
71
74
  assertEquals(30L, loaded.forwardInterval)
@@ -382,6 +385,36 @@ class PlayerConfigTest {
382
385
  assertEquals(RemoteControlHandling.JS, config.remoteControlHandling)
383
386
  }
384
387
 
388
+ @Test
389
+ fun `fromReadableMap defaults autoUpdateMetadataFromStream to true`() {
390
+ val map = mockk<ReadableMap>(relaxed = true) {
391
+ every { getString("contentType") } returns null
392
+ every { getMap("android") } returns null
393
+ every { getMap("cache") } returns null
394
+ every { getMap("progressSync") } returns null
395
+ every { hasKey(any()) } returns false
396
+ }
397
+
398
+ val config = PlayerConfig.fromReadableMap(map)
399
+ assertTrue(config.autoUpdateMetadataFromStream)
400
+ }
401
+
402
+ @Test
403
+ fun `fromReadableMap parses autoUpdateMetadataFromStream when false`() {
404
+ val map = mockk<ReadableMap>(relaxed = true) {
405
+ every { getString("contentType") } returns null
406
+ every { getMap("android") } returns null
407
+ every { getMap("cache") } returns null
408
+ every { getMap("progressSync") } returns null
409
+ every { hasKey(any()) } returns false
410
+ every { hasKey("autoUpdateMetadataFromStream") } returns true
411
+ every { getBoolean("autoUpdateMetadataFromStream") } returns false
412
+ }
413
+
414
+ val config = PlayerConfig.fromReadableMap(map)
415
+ assertFalse(config.autoUpdateMetadataFromStream)
416
+ }
417
+
385
418
  @Test
386
419
  fun `fromReadableMap with null android section defaults wakeMode to NONE`() {
387
420
  val map = mockk<ReadableMap>(relaxed = true) {
@@ -0,0 +1,254 @@
1
+ /*
2
+ * Copyright (c) Double Symmetry GmbH
3
+ * Commercial use requires a license. See https://rntp.dev/pricing
4
+ */
5
+
6
+ package com.doublesymmetry.trackplayer.models
7
+
8
+ import androidx.media3.common.Format
9
+ import androidx.media3.common.Metadata
10
+ import androidx.media3.extractor.metadata.MetadataInputBuffer
11
+ import androidx.media3.extractor.metadata.icy.IcyDecoder
12
+ import androidx.media3.extractor.metadata.icy.IcyHeaders
13
+ import androidx.media3.extractor.metadata.icy.IcyInfo
14
+ import androidx.media3.extractor.metadata.id3.TextInformationFrame
15
+ import com.google.common.collect.ImmutableList
16
+ import java.nio.ByteBuffer
17
+ import org.junit.Assert.assertEquals
18
+ import org.junit.Assert.assertNotNull
19
+ import org.junit.Assert.assertNull
20
+ import org.junit.Test
21
+ import org.junit.runner.RunWith
22
+ import org.robolectric.RobolectricTestRunner
23
+
24
+ /**
25
+ * Validates that the same parser path that ExoPlayer's
26
+ * [androidx.media3.exoplayer.source.ProgressiveMediaPeriod] uses for live ICY
27
+ * streams produces the metadata our JS layer expects. We feed the real
28
+ * [IcyDecoder] raw byte payloads in the exact wire format radio.co /
29
+ * klubradio.hu (Shoutcast/Icecast) emit, and assert the aggregated
30
+ * [MetadataReceivedEvent].
31
+ *
32
+ * Repro context — https://github.com/doublesymmetry/react-native-track-player/issues/2638
33
+ * - https://streamer.radio.co/s51a438b13/listen
34
+ * - https://stream.klubradio.hu:8443/
35
+ */
36
+ @RunWith(RobolectricTestRunner::class)
37
+ class StreamMetadataExtractorTest {
38
+
39
+ // ---- parseStreamTitle ----
40
+
41
+ @Test
42
+ fun `parseStreamTitle splits Artist - Title`() {
43
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky")
44
+ assertEquals("Get Lucky", title)
45
+ assertEquals("Daft Punk", artist)
46
+ }
47
+
48
+ @Test
49
+ fun `parseStreamTitle keeps single string as title`() {
50
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Morning News")
51
+ assertEquals("Morning News", title)
52
+ assertNull(artist)
53
+ }
54
+
55
+ @Test
56
+ fun `parseStreamTitle splits only on first separator`() {
57
+ val (title, artist) =
58
+ StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky - Radio Edit")
59
+ assertEquals("Get Lucky - Radio Edit", title)
60
+ assertEquals("Daft Punk", artist)
61
+ }
62
+
63
+ @Test
64
+ fun `parseStreamTitle trims surrounding whitespace`() {
65
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" Artist - Song ")
66
+ assertEquals("Song", title)
67
+ assertEquals("Artist", artist)
68
+ }
69
+
70
+ @Test
71
+ fun `parseStreamTitle returns nulls for blank input`() {
72
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" ")
73
+ assertNull(title)
74
+ assertNull(artist)
75
+ }
76
+
77
+ @Test
78
+ fun `parseStreamTitle does not split on dash without surrounding spaces`() {
79
+ // "U-Turn" should NOT be split — Shoutcast convention uses " - " (spaces).
80
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("U-Turn")
81
+ assertEquals("U-Turn", title)
82
+ assertNull(artist)
83
+ }
84
+
85
+ // ---- extract: synthetic IcyInfo entries ----
86
+
87
+ @Test
88
+ fun `extract from IcyInfo with Artist - Title splits both fields`() {
89
+ val info = IcyInfo(ByteArray(0), "Daft Punk - Get Lucky", null)
90
+ val event = StreamMetadataExtractor.extract(Metadata(info))
91
+
92
+ assertNotNull(event)
93
+ assertEquals("Get Lucky", event!!.title)
94
+ assertEquals("Daft Punk", event.artist)
95
+ assertNull(event.albumTitle)
96
+ assertNull(event.artworkUri)
97
+ assertNull(event.genre)
98
+ }
99
+
100
+ @Test
101
+ fun `extract from IcyInfo with single title leaves artist null`() {
102
+ val info = IcyInfo(ByteArray(0), "Morning Talk Show", null)
103
+ val event = StreamMetadataExtractor.extract(Metadata(info))
104
+
105
+ assertNotNull(event)
106
+ assertEquals("Morning Talk Show", event!!.title)
107
+ assertNull(event.artist)
108
+ }
109
+
110
+ @Test
111
+ fun `extract from IcyInfo with null title returns null event`() {
112
+ val info = IcyInfo(ByteArray(0), null, null)
113
+ val event = StreamMetadataExtractor.extract(Metadata(info))
114
+ assertNull(event)
115
+ }
116
+
117
+ @Test
118
+ fun `extract from empty Metadata returns null`() {
119
+ val event = StreamMetadataExtractor.extract(Metadata())
120
+ assertNull(event)
121
+ }
122
+
123
+ @Test
124
+ fun `extract maps IcyInfo url into artworkUri`() {
125
+ val info = IcyInfo(ByteArray(0), "Artist - Song", "https://example.com/cover.jpg")
126
+ val event = StreamMetadataExtractor.extract(Metadata(info))
127
+
128
+ assertNotNull(event)
129
+ assertEquals("https://example.com/cover.jpg", event!!.artworkUri)
130
+ }
131
+
132
+ @Test
133
+ fun `extract ignores empty IcyInfo url`() {
134
+ val info = IcyInfo(ByteArray(0), "Artist - Song", "")
135
+ val event = StreamMetadataExtractor.extract(Metadata(info))
136
+
137
+ assertNotNull(event)
138
+ assertNull(event!!.artworkUri)
139
+ }
140
+
141
+ // ---- extract: combined IcyInfo + IcyHeaders ----
142
+
143
+ @Test
144
+ fun `extract pulls genre from IcyHeaders alongside IcyInfo title`() {
145
+ // ProgressiveMediaPeriod emits IcyHeaders + IcyInfo together on first metadata
146
+ // for radio.co / klubradio.hu (icy-genre header from the response).
147
+ val info = IcyInfo(ByteArray(0), "Radiohead - No Surprises", null)
148
+ val headers = IcyHeaders(
149
+ /* bitrate = */ 128_000,
150
+ /* genre = */ "Indie Rock",
151
+ /* name = */ "Test Radio",
152
+ /* url = */ null,
153
+ /* isPublic = */ true,
154
+ /* metadataInterval = */ 16_000,
155
+ )
156
+ val event = StreamMetadataExtractor.extract(Metadata(info, headers))
157
+
158
+ assertNotNull(event)
159
+ assertEquals("No Surprises", event!!.title)
160
+ assertEquals("Radiohead", event.artist)
161
+ assertEquals("Indie Rock", event.genre)
162
+ }
163
+
164
+ // ---- extract: ID3 frames (covers podcasts / MP3 streams w/ embedded ID3) ----
165
+
166
+ @Test
167
+ fun `extract from ID3 frames sets title and artist without splitting`() {
168
+ // TIT2 = title, TPE1 = lead artist. When both arrive populated, the
169
+ // Shoutcast " - " heuristic must NOT split the title.
170
+ val title = TextInformationFrame("TIT2", null, ImmutableList.of("Song - With Dash"))
171
+ val artist = TextInformationFrame("TPE1", null, ImmutableList.of("Artist Name"))
172
+ val event = StreamMetadataExtractor.extract(Metadata(title, artist))
173
+
174
+ assertNotNull(event)
175
+ assertEquals("Song - With Dash", event!!.title)
176
+ assertEquals("Artist Name", event.artist)
177
+ }
178
+
179
+ // ---- extract: real captured ICY payloads from the reported streams ----
180
+ //
181
+ // The strings below are NOT illustrative examples — they are the literal
182
+ // bytes returned by the two stations the original bug report links to,
183
+ // captured live from production endpoints.
184
+ //
185
+ // Capture procedure (reproducible from `scripts/capture_icy.py`):
186
+ // $ scripts/capture_icy.py https://streamer.radio.co/s51a438b13/listen out.txt
187
+ // $ scripts/capture_icy.py https://stream.klubradio.hu:8443/ out.txt
188
+ // Both servers respond with `icy-metaint: 16000` and emit Shoutcast-style
189
+ // `StreamTitle='…';` blocks. NUL padding to the 16-byte block boundary has
190
+ // been stripped (matching IcyDecoder's behavior on the real device).
191
+
192
+ @Test
193
+ fun `extract real radio_co track-change payload (Sonia Odisho - Eked D Bayet)`() {
194
+ // Captured 2026-05-20 from https://streamer.radio.co/s51a438b13/listen
195
+ // Raw bytes (hex): 53 74 72 65 61 6d 54 69 74 6c 65 3d 27 53 6f 6e 69 61 …
196
+ val event = decodeAndExtract("StreamTitle='Sonia Odisho - Eked D Bayet';")
197
+ assertNotNull(event)
198
+ assertEquals("Eked D Bayet", event!!.title)
199
+ assertEquals("Sonia Odisho", event.artist)
200
+ assertNull(event.artworkUri)
201
+ }
202
+
203
+ @Test
204
+ fun `extract real radio_co between-songs empty StreamTitle`() {
205
+ // Same source, captured immediately before the track-change block above.
206
+ // Servers commonly emit `StreamTitle='';` during silence/transitions —
207
+ // the extractor must NOT synthesize a bogus event in that case.
208
+ val event = decodeAndExtract("StreamTitle='';")
209
+ assertNull(event)
210
+ }
211
+
212
+ @Test
213
+ fun `extract real klubradio_hu talk-segment empty StreamTitle`() {
214
+ // Captured 2026-05-20 from https://stream.klubradio.hu:8443/ (redirects
215
+ // to hu-stream03.klubradio.hu:8443/bpstream). The Hungarian talk station
216
+ // sends ICY metadata frames but with empty StreamTitle during talk
217
+ // segments — over a 60s window only this empty payload was observed.
218
+ val event = decodeAndExtract("StreamTitle='';")
219
+ assertNull(event)
220
+ }
221
+
222
+ @Test
223
+ fun `extract handles UTF-8 multibyte chars in real-world StreamTitle`() {
224
+ // Both endpoints serve UTF-8; while the captured radio.co block above
225
+ // was pure ASCII, exercising the high-byte path is critical because
226
+ // IcyDecoder falls back to ISO-8859-1 on UTF-8 decode failure (would
227
+ // mangle Hungarian/Icelandic/etc characters).
228
+ val event = decodeAndExtract("StreamTitle='Sigur Rós - Hoppípolla';")
229
+ assertNotNull(event)
230
+ assertEquals("Hoppípolla", event!!.title)
231
+ assertEquals("Sigur Rós", event.artist)
232
+ }
233
+
234
+ // ---- helpers ----
235
+
236
+ /**
237
+ * Drives the real [IcyDecoder] used by ExoPlayer's [ProgressiveMediaPeriod]
238
+ * with the given Shoutcast metadata string, then runs the result through
239
+ * [StreamMetadataExtractor]. This exercises the same code path live streams
240
+ * hit at runtime.
241
+ */
242
+ private fun decodeAndExtract(icyString: String): MetadataReceivedEvent? {
243
+ val bytes = icyString.toByteArray(Charsets.UTF_8)
244
+ val buffer = MetadataInputBuffer().apply {
245
+ data = ByteBuffer.allocate(bytes.size).apply {
246
+ put(bytes)
247
+ flip()
248
+ }
249
+ }
250
+ val decoded = IcyDecoder().decode(buffer)
251
+ assertNotNull("IcyDecoder should produce a Metadata bundle", decoded)
252
+ return StreamMetadataExtractor.extract(decoded!!)
253
+ }
254
+ }
@@ -26,6 +26,40 @@ class TrackPlayerMediaItemTest {
26
26
  // Arguments.createMap() and createArray() call into native code; replace with JavaOnlyMap.
27
27
  mockkStatic(Arguments::class)
28
28
  every { Arguments.createMap() } answers { JavaOnlyMap() }
29
+ // toBundle / fromBundle traverse the map with native helpers; for tests, hand-roll
30
+ // a small subset that covers the JSON-shaped values our extras use.
31
+ every { Arguments.toBundle(any()) } answers {
32
+ val src = firstArg<ReadableMap?>() ?: return@answers null
33
+ val bundle = android.os.Bundle()
34
+ val it = src.keySetIterator()
35
+ while (it.hasNextKey()) {
36
+ val k = it.nextKey()
37
+ when (src.getType(k)) {
38
+ ReadableType.Null -> bundle.putString(k, null)
39
+ ReadableType.Boolean -> bundle.putBoolean(k, src.getBoolean(k))
40
+ ReadableType.Number -> bundle.putDouble(k, src.getDouble(k))
41
+ ReadableType.String -> bundle.putString(k, src.getString(k))
42
+ else -> {} // skip nested maps/arrays for these tests
43
+ }
44
+ }
45
+ bundle
46
+ }
47
+ every { Arguments.fromBundle(any()) } answers {
48
+ // Arguments.fromBundle(Bundle): WritableMap is non-null in current RN.
49
+ val src = firstArg<android.os.Bundle>()
50
+ val map = JavaOnlyMap()
51
+ for (k in src.keySet()) {
52
+ when (val v = src.get(k)) {
53
+ null -> map.putNull(k)
54
+ is Boolean -> map.putBoolean(k, v)
55
+ is Double -> map.putDouble(k, v)
56
+ is Int -> map.putInt(k, v)
57
+ is String -> map.putString(k, v)
58
+ else -> {} // skip nested types for these tests
59
+ }
60
+ }
61
+ map
62
+ }
29
63
  }
30
64
 
31
65
  @After
@@ -377,4 +411,172 @@ class TrackPlayerMediaItemTest {
377
411
  assertEquals("cast-item-id", item.url)
378
412
  assertEquals("Cast Track", item.title)
379
413
  }
414
+
415
+ // ---- extras (app-defined payload) ----
416
+
417
+ @Test
418
+ fun `fromReadableMap parses extras when present`() {
419
+ val extrasMap = mockk<ReadableMap>(relaxed = true) {
420
+ every { keySetIterator() } returns mockk {
421
+ every { hasNextKey() } returnsMany listOf(true, true, true, false)
422
+ every { nextKey() } returnsMany listOf("source", "isrc", "weight")
423
+ }
424
+ every { getType("source") } returns ReadableType.String
425
+ every { getString("source") } returns "recommendations"
426
+ every { getType("isrc") } returns ReadableType.String
427
+ every { getString("isrc") } returns "USRC17607839"
428
+ every { getType("weight") } returns ReadableType.Number
429
+ every { getDouble("weight") } returns 0.85
430
+ }
431
+
432
+ val map = mockk<ReadableMap>(relaxed = true) {
433
+ every { hasKey("url") } returns true
434
+ every { getType("url") } returns mockk { every { name } returns "String" }
435
+ every { getString("url") } returns "https://example.com/track.mp3"
436
+ every { hasKey("mediaId") } returns false
437
+ every { hasKey("title") } returns false
438
+ every { hasKey("artist") } returns false
439
+ every { hasKey("albumTitle") } returns false
440
+ every { hasKey("artworkUrl") } returns false
441
+ every { hasKey("duration") } returns false
442
+ every { hasKey("isLive") } returns false
443
+ every { hasKey("mimeType") } returns false
444
+ every { hasKey("extras") } returns true
445
+ every { getMap("extras") } returns extrasMap
446
+ }
447
+
448
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
449
+
450
+ assertNotNull(item.extras)
451
+ assertEquals("recommendations", item.extras!!.getString("source"))
452
+ assertEquals("USRC17607839", item.extras!!.getString("isrc"))
453
+ assertEquals(0.85, item.extras!!.getDouble("weight"), 0.001)
454
+ }
455
+
456
+ @Test
457
+ fun `fromReadableMap leaves extras null when absent`() {
458
+ val map = mockk<ReadableMap>(relaxed = true) {
459
+ every { hasKey("url") } returns true
460
+ every { getType("url") } returns mockk { every { name } returns "String" }
461
+ every { getString("url") } returns "https://example.com/track.mp3"
462
+ every { hasKey("mediaId") } returns false
463
+ every { hasKey("title") } returns false
464
+ every { hasKey("artist") } returns false
465
+ every { hasKey("albumTitle") } returns false
466
+ every { hasKey("artworkUrl") } returns false
467
+ every { hasKey("duration") } returns false
468
+ every { hasKey("isLive") } returns false
469
+ every { hasKey("mimeType") } returns false
470
+ every { hasKey("extras") } returns false
471
+ }
472
+
473
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
474
+ assertNull(item.extras)
475
+ }
476
+
477
+ @Test
478
+ fun `asMediaItem stores extras under a namespaced key without polluting known fields`() {
479
+ val payload = android.os.Bundle().apply {
480
+ putString("source", "recommendations")
481
+ putString("isrc", "USRC17607839")
482
+ }
483
+ val item = TrackPlayerMediaItem(
484
+ url = "https://example.com/track.mp3",
485
+ duration = 120.0,
486
+ isLive = false,
487
+ extras = payload,
488
+ )
489
+
490
+ val mediaItem = item.asMediaItem()
491
+ val metadataExtras = mediaItem.mediaMetadata.extras
492
+
493
+ assertNotNull(metadataExtras)
494
+ // Known fields still live at the top level of the metadata extras bundle…
495
+ assertEquals(120.0, metadataExtras!!.getDouble("duration"), 0.001)
496
+ assertFalse(metadataExtras.getBoolean("isLive"))
497
+ // …and the app payload is nested under our namespaced key.
498
+ val nested = metadataExtras.getBundle("rntp.extras")
499
+ assertNotNull(nested)
500
+ assertEquals("recommendations", nested!!.getString("source"))
501
+ assertEquals("USRC17607839", nested.getString("isrc"))
502
+ }
503
+
504
+ @Test
505
+ fun `asMediaItem omits extras bundle when no extras provided`() {
506
+ val item = TrackPlayerMediaItem(
507
+ url = "https://example.com/track.mp3",
508
+ )
509
+
510
+ val mediaItem = item.asMediaItem()
511
+ val metadataExtras = mediaItem.mediaMetadata.extras
512
+
513
+ // The metadata extras bundle is always created (for duration/isLive),
514
+ // but our namespaced key should be absent.
515
+ assertNotNull(metadataExtras)
516
+ assertFalse(metadataExtras!!.containsKey("rntp.extras"))
517
+ }
518
+
519
+ @Test
520
+ fun `toWritableMap includes extras when present`() {
521
+ val payload = android.os.Bundle().apply {
522
+ putString("source", "recommendations")
523
+ putDouble("weight", 0.85)
524
+ }
525
+ val item = TrackPlayerMediaItem(
526
+ url = "https://example.com/track.mp3",
527
+ extras = payload,
528
+ )
529
+
530
+ val map = item.toWritableMap()
531
+ assertTrue(map.hasKey("extras"))
532
+ val extras = map.getMap("extras")
533
+ assertNotNull(extras)
534
+ assertEquals("recommendations", extras!!.getString("source"))
535
+ assertEquals(0.85, extras.getDouble("weight"), 0.001)
536
+ }
537
+
538
+ @Test
539
+ fun `toWritableMap omits extras when absent`() {
540
+ val item = TrackPlayerMediaItem(
541
+ url = "https://example.com/track.mp3",
542
+ )
543
+
544
+ val map = item.toWritableMap()
545
+ assertFalse(map.hasKey("extras"))
546
+ }
547
+
548
+ @Test
549
+ fun `extras survive full round-trip through Media3 MediaItem`() {
550
+ val payload = android.os.Bundle().apply {
551
+ putString("source", "recommendations")
552
+ putString("isrc", "USRC17607839")
553
+ putDouble("weight", 0.85)
554
+ putBoolean("explicit", true)
555
+ }
556
+ val original = TrackPlayerMediaItem(
557
+ mediaId = "t-1",
558
+ url = "https://example.com/track.mp3",
559
+ title = "Song",
560
+ duration = 60.0,
561
+ extras = payload,
562
+ )
563
+
564
+ val restored = TrackPlayerMediaItem.fromMediaItem(original.asMediaItem())
565
+
566
+ assertEquals("t-1", restored.mediaId)
567
+ assertEquals("Song", restored.title)
568
+ assertEquals(60.0, restored.duration!!, 0.001)
569
+ assertNotNull(restored.extras)
570
+ assertEquals("recommendations", restored.extras!!.getString("source"))
571
+ assertEquals("USRC17607839", restored.extras!!.getString("isrc"))
572
+ assertEquals(0.85, restored.extras!!.getDouble("weight"), 0.001)
573
+ assertTrue(restored.extras!!.getBoolean("explicit"))
574
+ }
575
+
576
+ @Test
577
+ fun `fromMediaItem leaves extras null when source has none`() {
578
+ val original = TrackPlayerMediaItem(url = "https://example.com/track.mp3")
579
+ val restored = TrackPlayerMediaItem.fromMediaItem(original.asMediaItem())
580
+ assertNull(restored.extras)
581
+ }
380
582
  }
@@ -20,6 +20,8 @@ class TrackPlayer: RCTEventEmitter {
20
20
  private var progressSyncTimer: Timer?
21
21
  private var progressSyncIntervalSeconds: Double = 0
22
22
  private var progressSyncHttpUrl: String?
23
+ private var autoUpdateMetadataFromStream: Bool = true
24
+ private var lastEmittedMediaMetadata: MediaMetadataChangedEvent?
23
25
  private var progressSyncHttpHeaders: [String: String]?
24
26
 
25
27
  private var sleepTimerController: SleepTimerController?
@@ -76,6 +78,7 @@ class TrackPlayer: RCTEventEmitter {
76
78
  audioCache = cache
77
79
 
78
80
  let handleNoisy = config["handleAudioBecomingNoisy"] as? Bool ?? true
81
+ autoUpdateMetadataFromStream = config["autoUpdateMetadataFromStream"] as? Bool ?? true
79
82
  player = AudioPlayer(handleAudioBecomingNoisy: handleNoisy, cache: cache)
80
83
  player.preloadWindow = preloadWindow
81
84
  bindPlayerCallbacks()
@@ -312,6 +315,9 @@ class TrackPlayer: RCTEventEmitter {
312
315
  )
313
316
 
314
317
  player.updateMetadata(at: idx, with: updated)
318
+ if idx == player.currentIndex {
319
+ emitMediaMetadataChangedIfNeeded(for: updated)
320
+ }
315
321
  }
316
322
 
317
323
  // MARK: - State Getters (sync)
@@ -540,14 +546,18 @@ class TrackPlayer: RCTEventEmitter {
540
546
  let mediaItem = item as? MediaItem
541
547
  let dict = mediaItem?.toDictionary()
542
548
  BrowseTreeStore.shared.updateNowPlaying(mediaId: mediaItem?.mediaId)
549
+ lastEmittedMediaMetadata = nil
543
550
  emitEvent(event: MediaItemTransitionEvent(item: dict, index: index))
551
+ emitMediaMetadataChangedIfNeeded(for: mediaItem)
544
552
  }
545
553
 
546
554
  func handleMetadataReceived(_ metadata: StreamMetadata) {
555
+ emitEvent(event: MetadataReceivedEvent(metadata: metadata))
556
+
557
+ guard autoUpdateMetadataFromStream else { return }
547
558
  let idx = player.currentIndex
548
559
  guard idx >= 0 && idx < player.items.count,
549
560
  let existing = player.items[idx] as? MediaItem else { return }
550
-
551
561
  let updated = existing.withMetadata(
552
562
  title: metadata.title.map { .some($0) },
553
563
  artist: metadata.artist.map { .some($0) },
@@ -555,7 +565,23 @@ class TrackPlayer: RCTEventEmitter {
555
565
  artworkUrl: metadata.artworkUri.flatMap { MediaURL(object: $0) }.map { .some($0) }
556
566
  )
557
567
  player.updateMetadata(at: idx, with: updated)
558
- emitEvent(event: MetadataReceivedEvent(metadata: metadata))
568
+ emitMediaMetadataChangedIfNeeded(for: updated)
569
+ }
570
+
571
+ /// iOS MediaItem has no `genre`; Android may include it in this event.
572
+ private func emitMediaMetadataChangedIfNeeded(for item: MediaItem?) {
573
+ guard let item else { return }
574
+ let event = MediaMetadataChangedEvent(
575
+ title: item.title,
576
+ artist: item.artist,
577
+ albumTitle: item.albumTitle,
578
+ artworkUrl: item.artworkUrl.map { $0.isLocal ? $0.value.path : $0.value.absoluteString },
579
+ genre: nil
580
+ )
581
+ if event.isEmpty { return }
582
+ if event == lastEmittedMediaMetadata { return }
583
+ lastEmittedMediaMetadata = event
584
+ emitEvent(event: event)
559
585
  }
560
586
 
561
587
  // MARK: - Progress Sync
@@ -16,6 +16,7 @@ enum EmitEventType: String, CaseIterable {
16
16
  case RemoteStop = "event.remote-stop"
17
17
  case RemoteSeek = "event.remote-seek"
18
18
  case MetadataReceived = "event.metadata-received"
19
+ case MediaMetadataChanged = "event.media-metadata-changed"
19
20
  case RemoteSkipForward = "event.remote-skip-forward"
20
21
  case RemoteSkipBackward = "event.remote-skip-backward"
21
22
  case PlaybackProgressUpdated = "event.playback-progress-updated"
@@ -127,6 +128,33 @@ struct MetadataReceivedEvent: EmitEvent {
127
128
  }
128
129
  }
129
130
 
131
+ /// Effective metadata of the currently active MediaItem after any merge of
132
+ /// stream-derived metadata onto user-supplied fields. Mirrors Android's
133
+ /// `MediaMetadataChangedEvent` and the JS `Event.MediaMetadataChanged`.
134
+ struct MediaMetadataChangedEvent: EmitEvent, Equatable {
135
+ let title: String?
136
+ let artist: String?
137
+ let albumTitle: String?
138
+ let artworkUrl: String?
139
+ let genre: String?
140
+
141
+ var eventType: EmitEventType { .MediaMetadataChanged }
142
+ var body: [String: Any] {
143
+ var result: [String: Any] = [:]
144
+ if let title { result["title"] = title }
145
+ if let artist { result["artist"] = artist }
146
+ if let albumTitle { result["albumTitle"] = albumTitle }
147
+ if let artworkUrl { result["artworkUrl"] = artworkUrl }
148
+ if let genre { result["genre"] = genre }
149
+ return result
150
+ }
151
+
152
+ var isEmpty: Bool {
153
+ return title == nil && artist == nil && albumTitle == nil &&
154
+ artworkUrl == nil && genre == nil
155
+ }
156
+ }
157
+
130
158
  struct RemoteSkipForwardEvent: EmitEvent {
131
159
  let interval: Double
132
160
 
@@ -16,8 +16,9 @@ struct MediaItem {
16
16
  let artworkUrl: MediaURL?
17
17
  let duration: Double?
18
18
  let isLiveStream: Bool?
19
+ let extras: [String: Any]?
19
20
 
20
- init(mediaId: String, url: MediaURL, title: String?, artist: String?, albumTitle: String?, artworkUrl: MediaURL?, duration: Double? = nil, isLiveStream: Bool? = nil) {
21
+ init(mediaId: String, url: MediaURL, title: String?, artist: String?, albumTitle: String?, artworkUrl: MediaURL?, duration: Double? = nil, isLiveStream: Bool? = nil, extras: [String: Any]? = nil) {
21
22
  self.mediaId = mediaId
22
23
  self.url = url
23
24
  self.title = title
@@ -26,6 +27,7 @@ struct MediaItem {
26
27
  self.artworkUrl = artworkUrl
27
28
  self.duration = duration
28
29
  self.isLiveStream = isLiveStream
30
+ self.extras = extras
29
31
  }
30
32
 
31
33
  init(data: [String: Any]) {
@@ -40,8 +42,9 @@ struct MediaItem {
40
42
  let artworkUrl = MediaURL(object: data["artworkUrl"])
41
43
  let duration = data["duration"] as? Double
42
44
  let isLiveStream = data["isLive"] as? Bool
45
+ let extras = data["extras"] as? [String: Any]
43
46
 
44
- self.init(mediaId: mediaId, url: url, title: title, artist: artist, albumTitle: albumTitle, artworkUrl: artworkUrl, duration: duration, isLiveStream: isLiveStream)
47
+ self.init(mediaId: mediaId, url: url, title: title, artist: artist, albumTitle: albumTitle, artworkUrl: artworkUrl, duration: duration, isLiveStream: isLiveStream, extras: extras)
45
48
  }
46
49
  }
47
50
 
@@ -60,7 +63,8 @@ extension MediaItem {
60
63
  albumTitle: albumTitle ?? self.albumTitle,
61
64
  artworkUrl: artworkUrl ?? self.artworkUrl,
62
65
  duration: self.duration,
63
- isLiveStream: self.isLiveStream
66
+ isLiveStream: self.isLiveStream,
67
+ extras: self.extras
64
68
  )
65
69
  }
66
70
 
@@ -77,6 +81,7 @@ extension MediaItem {
77
81
  }
78
82
  if let duration = duration { dict["duration"] = duration }
79
83
  if let isLiveStream = isLiveStream { dict["isLive"] = isLiveStream }
84
+ if let extras = extras { dict["extras"] = extras }
80
85
  return dict
81
86
  }
82
87
  }