@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.
- package/android/src/main/java/com/doublesymmetry/trackplayer/HeaderInjectingDataSourceFactory.kt +5 -1
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +23 -3
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +22 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +36 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +10 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractor.kt +98 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItem.kt +20 -8
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +33 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/MetadataApplierTest.kt +224 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +33 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractorTest.kt +254 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +202 -0
- package/ios/TrackPlayer.swift +28 -2
- package/ios/models/EmitEvent.swift +28 -0
- package/ios/models/MediaItem.swift +8 -3
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +7 -4
- package/lib/commonjs/audio.js +1 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/events/MediaMetadataChanged.js +2 -0
- package/lib/commonjs/events/MediaMetadataChanged.js.map +1 -0
- package/lib/commonjs/events/index.js +13 -0
- package/lib/commonjs/events/index.js.map +1 -1
- package/lib/commonjs/hooks/useActiveMediaItem.js +3 -3
- package/lib/commonjs/hooks/useActiveMediaItem.js.map +1 -1
- package/lib/commonjs/hooks/useProgress.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -0
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/audio.js +1 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/events/MediaMetadataChanged.js +2 -0
- package/lib/module/events/MediaMetadataChanged.js.map +1 -0
- package/lib/module/events/index.js +2 -0
- package/lib/module/events/index.js.map +1 -1
- package/lib/module/hooks/useActiveMediaItem.js +3 -3
- package/lib/module/hooks/useActiveMediaItem.js.map +1 -1
- package/lib/module/hooks/useProgress.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -0
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/events/MediaMetadataChanged.d.ts +18 -0
- package/lib/typescript/src/events/MediaMetadataChanged.d.ts.map +1 -0
- package/lib/typescript/src/events/MetadataReceived.d.ts +15 -0
- package/lib/typescript/src/events/MetadataReceived.d.ts.map +1 -1
- package/lib/typescript/src/events/index.d.ts +4 -0
- package/lib/typescript/src/events/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useActiveMediaItem.d.ts +2 -2
- package/lib/typescript/src/hooks/useProgress.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +15 -0
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +8 -0
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/events/MediaMetadataChanged.ts +18 -0
- package/src/events/MetadataReceived.ts +15 -0
- package/src/events/index.ts +4 -0
- package/src/hooks/useActiveMediaItem.ts +3 -3
- package/src/hooks/useProgress.ts +1 -1
- package/src/interfaces/MediaItem.ts +15 -0
- 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) {
|
package/android/src/test/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractorTest.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt
CHANGED
|
@@ -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
|
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|