@rntp/player 5.0.0 → 5.1.0
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 +43 -3
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +30 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +59 -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 +243 -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 +49 -2
- package/ios/models/EmitEvent.swift +28 -0
- package/ios/models/MediaItem.swift +8 -3
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +13 -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 +9 -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 +9 -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 +31 -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 +8 -2
- package/lib/typescript/src/hooks/useActiveMediaItem.d.ts.map +1 -1
- 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 +15 -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 +31 -0
- package/src/events/MetadataReceived.ts +15 -0
- package/src/events/index.ts +4 -0
- package/src/hooks/useActiveMediaItem.ts +9 -3
- package/src/hooks/useProgress.ts +1 -1
- package/src/interfaces/MediaItem.ts +15 -0
- package/src/interfaces/PlayerConfig.ts +16 -0
|
@@ -0,0 +1,243 @@
|
|
|
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.MediaItem
|
|
9
|
+
import androidx.media3.common.MediaMetadata
|
|
10
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
11
|
+
import org.junit.After
|
|
12
|
+
import org.junit.Assert.assertEquals
|
|
13
|
+
import org.junit.Assert.assertFalse
|
|
14
|
+
import org.junit.Assert.assertNull
|
|
15
|
+
import org.junit.Assert.assertTrue
|
|
16
|
+
import org.junit.Before
|
|
17
|
+
import org.junit.Test
|
|
18
|
+
import org.junit.runner.RunWith
|
|
19
|
+
import org.robolectric.RobolectricTestRunner
|
|
20
|
+
import org.robolectric.RuntimeEnvironment
|
|
21
|
+
import org.robolectric.annotation.Config
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exercises [MetadataApplier.applyToCurrentMediaItem] against a real
|
|
25
|
+
* [ExoPlayer] under Robolectric. We use [ExoPlayer] directly (rather than the
|
|
26
|
+
* [androidx.media3.session.MediaController] used at runtime) because both
|
|
27
|
+
* implement the [androidx.media3.common.Player] interface that the applier
|
|
28
|
+
* targets — and ExoPlayer is the only one we can stand up without a service.
|
|
29
|
+
*/
|
|
30
|
+
@RunWith(RobolectricTestRunner::class)
|
|
31
|
+
@Config(sdk = [33])
|
|
32
|
+
class MetadataApplierTest {
|
|
33
|
+
|
|
34
|
+
private lateinit var player: ExoPlayer
|
|
35
|
+
|
|
36
|
+
@Before
|
|
37
|
+
fun setUp() {
|
|
38
|
+
val context = RuntimeEnvironment.getApplication()
|
|
39
|
+
player = ExoPlayer.Builder(context).build()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@After
|
|
43
|
+
fun tearDown() {
|
|
44
|
+
player.release()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun buildItem(
|
|
48
|
+
id: String,
|
|
49
|
+
title: String? = null,
|
|
50
|
+
artist: String? = null,
|
|
51
|
+
albumTitle: String? = null,
|
|
52
|
+
genre: String? = null,
|
|
53
|
+
): MediaItem = MediaItem.Builder()
|
|
54
|
+
.setMediaId(id)
|
|
55
|
+
.setUri("https://example.com/$id.mp3")
|
|
56
|
+
.setMediaMetadata(
|
|
57
|
+
MediaMetadata.Builder().apply {
|
|
58
|
+
title?.let { setTitle(it) }
|
|
59
|
+
artist?.let { setArtist(it) }
|
|
60
|
+
albumTitle?.let { setAlbumTitle(it) }
|
|
61
|
+
genre?.let { setGenre(it) }
|
|
62
|
+
}.build()
|
|
63
|
+
)
|
|
64
|
+
.build()
|
|
65
|
+
|
|
66
|
+
// ---- Happy path: live ICY-style updates rewrite the current item ----
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `applies title and artist to current item`() {
|
|
70
|
+
player.setMediaItems(listOf(buildItem("radio")))
|
|
71
|
+
val event = MetadataReceivedEvent(title = "Hoppípolla", artist = "Sigur Rós")
|
|
72
|
+
|
|
73
|
+
val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
74
|
+
|
|
75
|
+
assertTrue(applied)
|
|
76
|
+
val updated = player.getMediaItemAt(0).mediaMetadata
|
|
77
|
+
assertEquals("Hoppípolla", updated.title?.toString())
|
|
78
|
+
assertEquals("Sigur Rós", updated.artist?.toString())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@Test
|
|
82
|
+
fun `preserves existing user-supplied fields the event does not touch`() {
|
|
83
|
+
// Mirrors the real-world case: an app queues a radio station with a fixed
|
|
84
|
+
// `genre` / `albumTitle` and the stream subsequently sends new ICY titles
|
|
85
|
+
// without artwork/genre. Those originals must survive the rewrite.
|
|
86
|
+
player.setMediaItems(
|
|
87
|
+
listOf(
|
|
88
|
+
buildItem(
|
|
89
|
+
id = "radio",
|
|
90
|
+
title = "Static Station Title",
|
|
91
|
+
artist = null,
|
|
92
|
+
albumTitle = "My Radios",
|
|
93
|
+
genre = "Rock",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
val event = MetadataReceivedEvent(title = "New Song", artist = "New Artist")
|
|
98
|
+
|
|
99
|
+
MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
100
|
+
|
|
101
|
+
val updated = player.getMediaItemAt(0).mediaMetadata
|
|
102
|
+
assertEquals("New Song", updated.title?.toString())
|
|
103
|
+
assertEquals("New Artist", updated.artist?.toString())
|
|
104
|
+
assertEquals("My Radios", updated.albumTitle?.toString())
|
|
105
|
+
assertEquals("Rock", updated.genre?.toString())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
fun `overwrites previous stream-supplied title on next update`() {
|
|
110
|
+
// Two consecutive ICY updates: second one fully replaces the first's
|
|
111
|
+
// title + artist (same currentIndex, so the rewrite is in place).
|
|
112
|
+
player.setMediaItems(listOf(buildItem("radio")))
|
|
113
|
+
|
|
114
|
+
MetadataApplier.applyToCurrentMediaItem(
|
|
115
|
+
player,
|
|
116
|
+
MetadataReceivedEvent(title = "Song A", artist = "Artist A"),
|
|
117
|
+
)
|
|
118
|
+
MetadataApplier.applyToCurrentMediaItem(
|
|
119
|
+
player,
|
|
120
|
+
MetadataReceivedEvent(title = "Song B", artist = "Artist B"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
val updated = player.getMediaItemAt(0).mediaMetadata
|
|
124
|
+
assertEquals("Song B", updated.title?.toString())
|
|
125
|
+
assertEquals("Artist B", updated.artist?.toString())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@Test
|
|
129
|
+
fun `null fields in event do not clobber existing values (sticky semantics)`() {
|
|
130
|
+
// Match iOS withMetadata behavior: nil from stream means "leave alone",
|
|
131
|
+
// not "clear". Useful when a station sends artist on one update and only
|
|
132
|
+
// title on the next.
|
|
133
|
+
player.setMediaItems(listOf(buildItem(id = "radio", artist = "Existing Artist")))
|
|
134
|
+
val event = MetadataReceivedEvent(title = "Just a Title", artist = null)
|
|
135
|
+
|
|
136
|
+
MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
137
|
+
|
|
138
|
+
val updated = player.getMediaItemAt(0).mediaMetadata
|
|
139
|
+
assertEquals("Just a Title", updated.title?.toString())
|
|
140
|
+
assertEquals("Existing Artist", updated.artist?.toString())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- URI preserved → playback uninterrupted ----
|
|
144
|
+
|
|
145
|
+
@Test
|
|
146
|
+
fun `preserves URI on rewrite`() {
|
|
147
|
+
player.setMediaItems(listOf(buildItem("radio")))
|
|
148
|
+
val originalUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
|
|
149
|
+
|
|
150
|
+
MetadataApplier.applyToCurrentMediaItem(
|
|
151
|
+
player,
|
|
152
|
+
MetadataReceivedEvent(title = "Song", artist = "Artist"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
val newUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
|
|
156
|
+
assertEquals(originalUri, newUri)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Test
|
|
160
|
+
fun `preserves mediaId on rewrite`() {
|
|
161
|
+
player.setMediaItems(listOf(buildItem("my-radio-id", title = "Original")))
|
|
162
|
+
|
|
163
|
+
MetadataApplier.applyToCurrentMediaItem(
|
|
164
|
+
player,
|
|
165
|
+
MetadataReceivedEvent(title = "Live Title", artist = null),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
assertEquals("my-radio-id", player.getMediaItemAt(0).mediaId)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- artworkUri / albumTitle / genre coverage ----
|
|
172
|
+
|
|
173
|
+
@Test
|
|
174
|
+
fun `applies artworkUri and genre from event`() {
|
|
175
|
+
player.setMediaItems(listOf(buildItem("radio")))
|
|
176
|
+
val event = MetadataReceivedEvent(
|
|
177
|
+
title = "Song",
|
|
178
|
+
artist = "Artist",
|
|
179
|
+
albumTitle = "Album",
|
|
180
|
+
artworkUri = "https://art.example.com/cover.jpg",
|
|
181
|
+
genre = "Indie",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
185
|
+
|
|
186
|
+
val updated = player.getMediaItemAt(0).mediaMetadata
|
|
187
|
+
assertEquals("Album", updated.albumTitle?.toString())
|
|
188
|
+
assertEquals("https://art.example.com/cover.jpg", updated.artworkUri?.toString())
|
|
189
|
+
assertEquals("Indie", updated.genre?.toString())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- No current item → no-op ----
|
|
193
|
+
|
|
194
|
+
@Test
|
|
195
|
+
fun `returns false when queue is empty`() {
|
|
196
|
+
val event = MetadataReceivedEvent(title = "Won't Apply", artist = null)
|
|
197
|
+
|
|
198
|
+
val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
199
|
+
|
|
200
|
+
assertFalse(applied)
|
|
201
|
+
assertEquals(0, player.mediaItemCount)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- Affects only the current index, not siblings ----
|
|
205
|
+
|
|
206
|
+
@Test
|
|
207
|
+
fun `only mutates the current item, not the rest of the queue`() {
|
|
208
|
+
player.setMediaItems(
|
|
209
|
+
listOf(
|
|
210
|
+
buildItem("track-1", title = "Title 1"),
|
|
211
|
+
buildItem("track-2", title = "Title 2"),
|
|
212
|
+
buildItem("track-3", title = "Title 3"),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
// currentIndex defaults to 0 — that's the one we expect to mutate.
|
|
216
|
+
val event = MetadataReceivedEvent(title = "Live Title 1", artist = null)
|
|
217
|
+
|
|
218
|
+
MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
219
|
+
|
|
220
|
+
assertEquals("Live Title 1", player.getMediaItemAt(0).mediaMetadata.title?.toString())
|
|
221
|
+
assertEquals("Title 2", player.getMediaItemAt(1).mediaMetadata.title?.toString())
|
|
222
|
+
assertEquals("Title 3", player.getMediaItemAt(2).mediaMetadata.title?.toString())
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- Edge case: event with all null fields contributes nothing ----
|
|
226
|
+
|
|
227
|
+
@Test
|
|
228
|
+
fun `event with all null fields leaves item unchanged but still reports applied`() {
|
|
229
|
+
// The applier doesn't gate on empty events — that's the caller's job
|
|
230
|
+
// (StreamMetadataExtractor.extract already returns null for empty
|
|
231
|
+
// metadata blocks, so this code path is mostly defensive).
|
|
232
|
+
player.setMediaItems(listOf(buildItem(id = "radio", title = "Original Title")))
|
|
233
|
+
val event = MetadataReceivedEvent(
|
|
234
|
+
title = null, artist = null, albumTitle = null, artworkUri = null, genre = null,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
|
|
238
|
+
|
|
239
|
+
assertTrue(applied)
|
|
240
|
+
assertEquals("Original Title", player.getMediaItemAt(0).mediaMetadata.title?.toString())
|
|
241
|
+
assertNull(player.getMediaItemAt(0).mediaMetadata.artist)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -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
|
+
}
|