@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
@@ -34,7 +34,11 @@ class HeaderInjectingDataSourceFactory(
34
34
  override fun open(dataSpec: DataSpec): Long {
35
35
  val perItemHeaders = MediaHeaders.get(dataSpec.uri.toString())
36
36
  if (perItemHeaders != null) {
37
- val merged = perItemHeaders + dataSpec.httpRequestHeaders
37
+ // Per-item headers must win over defaults so callers can override headers
38
+ // injected by ExoPlayer (e.g. `Icy-MetaData: 1` from ProgressiveMediaPeriod
39
+ // can be turned off by passing `Icy-MetaData: 0` per item to avoid the
40
+ // server interleaving metadata into a stream the player can't strip).
41
+ val merged = dataSpec.httpRequestHeaders + perItemHeaders
38
42
  val newSpec = dataSpec.buildUpon()
39
43
  .setHttpRequestHeaders(merged)
40
44
  .build()
@@ -9,6 +9,7 @@ import android.content.ComponentName
9
9
  import android.os.Bundle
10
10
  import androidx.media3.common.MediaItem
11
11
  import androidx.media3.common.MediaMetadata
12
+ import androidx.media3.common.Metadata
12
13
  import androidx.media3.common.PlaybackException
13
14
  import androidx.media3.common.Player
14
15
  import androidx.media3.session.MediaController
@@ -29,12 +30,15 @@ import com.doublesymmetry.trackplayer.models.BrowseTree
29
30
  import com.doublesymmetry.trackplayer.models.MediaHeaders
30
31
  import com.doublesymmetry.trackplayer.models.IsPlayingChangedEvent
31
32
  import com.doublesymmetry.trackplayer.models.MediaItemTransitionEvent
33
+ import com.doublesymmetry.trackplayer.models.MediaMetadataChangedEvent
32
34
  import com.doublesymmetry.trackplayer.models.MetadataReceivedEvent
33
35
  import com.doublesymmetry.trackplayer.models.PlaybackErrorEvent
34
36
  import com.doublesymmetry.trackplayer.models.PlaybackStateChangedEvent
35
37
  import com.doublesymmetry.trackplayer.models.QueueChangedEvent
38
+ import com.doublesymmetry.trackplayer.models.MetadataApplier
36
39
  import com.doublesymmetry.trackplayer.models.PlayerConfig
37
40
  import com.doublesymmetry.trackplayer.models.PlaybackProgressUpdatedEvent
41
+ import com.doublesymmetry.trackplayer.models.StreamMetadataExtractor
38
42
  import org.json.JSONObject
39
43
 
40
44
  class TrackPlayerModule internal constructor(private val context: ReactApplicationContext) : TrackPlayerSpec(context), Player.Listener {
@@ -43,6 +47,8 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
43
47
 
44
48
  private val controller = MainThreadMediaController()
45
49
  private var lastStreamMetadata: MetadataReceivedEvent? = null
50
+ private var lastMediaMetadata: MediaMetadataChangedEvent? = null
51
+ @Volatile private var autoUpdateMetadataFromStream: Boolean = true
46
52
 
47
53
  // endregion
48
54
 
@@ -60,6 +66,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
60
66
  override fun setupPlayer(map: ReadableMap) {
61
67
  val config = PlayerConfig.fromReadableMap(map)
62
68
  config.store(context)
69
+ autoUpdateMetadataFromStream = config.autoUpdateMetadataFromStream
63
70
 
64
71
  controller.run {
65
72
  val sessionToken = SessionToken(context, ComponentName(context, TrackPlayerPlaybackService::class.java))
@@ -636,6 +643,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
636
643
 
637
644
  override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
638
645
  lastStreamMetadata = null
646
+ lastMediaMetadata = null
639
647
  val mc = controller.get() ?: return
640
648
  val item = if (mediaItem != null) TrackPlayerMediaItem.fromMediaItem(mediaItem).toWritableMap() else null
641
649
  context.emitEvent(MediaItemTransitionEvent(item, mc.currentMediaItemIndex))
@@ -660,7 +668,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
660
668
  }
661
669
 
662
670
  override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
663
- val event = MetadataReceivedEvent(
671
+ val event = MediaMetadataChangedEvent(
664
672
  title = mediaMetadata.title?.toString(),
665
673
  artist = mediaMetadata.artist?.toString(),
666
674
  albumTitle = mediaMetadata.albumTitle?.toString(),
@@ -669,11 +677,23 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
669
677
  )
670
678
  if (event.title == null && event.artist == null && event.albumTitle == null
671
679
  && event.artworkUri == null && event.genre == null) return
672
- if (event == lastStreamMetadata) return
673
- lastStreamMetadata = event
680
+ if (event == lastMediaMetadata) return
681
+ lastMediaMetadata = event
674
682
  context.emitEvent(event)
675
683
  }
676
684
 
685
+ override fun onMetadata(metadata: Metadata) {
686
+ val event = StreamMetadataExtractor.extract(metadata) ?: return
687
+ if (event != lastStreamMetadata) {
688
+ lastStreamMetadata = event
689
+ context.emitEvent(event)
690
+ }
691
+ if (autoUpdateMetadataFromStream) {
692
+ val mc = controller.get() ?: return
693
+ MetadataApplier.applyToCurrentMediaItem(mc, event)
694
+ }
695
+ }
696
+
677
697
  // endregion
678
698
 
679
699
  // region Helpers
@@ -9,6 +9,7 @@ enum class EmitEventType(val value: String) {
9
9
  PLAYBACK_STATE_CHANGED("event.playback-state-changed"),
10
10
  IS_PLAYING_CHANGED("event.is-playing-changed"),
11
11
  MEDIA_ITEM_TRANSITION("event.media-item-transition"),
12
+ MEDIA_METADATA_CHANGED("event.media-metadata-changed"),
12
13
  METADATA_RECEIVED("event.metadata-received"),
13
14
  PLAYBACK_ERROR("event.playback-error"),
14
15
  QUEUE_CHANGED("event.queue-changed"),
@@ -84,6 +85,27 @@ data class MetadataReceivedEvent(
84
85
  }
85
86
  }
86
87
 
88
+ /** Effective active-item metadata (Media3 [Player.Listener.onMediaMetadataChanged]). */
89
+ data class MediaMetadataChangedEvent(
90
+ val title: String?,
91
+ val artist: String?,
92
+ val albumTitle: String? = null,
93
+ val artworkUri: String? = null,
94
+ val genre: String? = null,
95
+ ): EmitEvent {
96
+ override val type = EmitEventType.MEDIA_METADATA_CHANGED
97
+
98
+ override fun pairs(): Array<Pair<String, Any>> {
99
+ val result = mutableListOf<Pair<String, Any>>()
100
+ title?.let { result.add("title" to it) }
101
+ artist?.let { result.add("artist" to it) }
102
+ albumTitle?.let { result.add("albumTitle" to it) }
103
+ artworkUri?.let { result.add("artworkUrl" to it) }
104
+ genre?.let { result.add("genre" to it) }
105
+ return result.toTypedArray()
106
+ }
107
+ }
108
+
87
109
  class QueueChangedEvent : EmitEvent {
88
110
  override val type = EmitEventType.QUEUE_CHANGED
89
111
  override fun pairs(): Array<Pair<String, Any>> = arrayOf()
@@ -0,0 +1,36 @@
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 android.net.Uri
9
+ import androidx.media3.common.MediaMetadata
10
+ import androidx.media3.common.Player
11
+
12
+ /** Merges stream metadata into the current [Player] media item (see [PlayerConfig.autoUpdateMetadataFromStream]). */
13
+ internal object MetadataApplier {
14
+
15
+ fun applyToCurrentMediaItem(player: Player, event: MetadataReceivedEvent): Boolean {
16
+ val idx = player.currentMediaItemIndex
17
+ if (idx < 0 || idx >= player.mediaItemCount) return false
18
+
19
+ val existing = player.getMediaItemAt(idx)
20
+ val newMeta = MediaMetadata.Builder()
21
+ .populate(existing.mediaMetadata)
22
+ .apply {
23
+ event.title?.let { setTitle(it) }
24
+ event.artist?.let { setArtist(it) }
25
+ event.albumTitle?.let { setAlbumTitle(it) }
26
+ event.artworkUri?.let { setArtworkUri(Uri.parse(it)) }
27
+ event.genre?.let { setGenre(it) }
28
+ }
29
+ .build()
30
+ val updated = existing.buildUpon()
31
+ .setMediaMetadata(newMeta)
32
+ .build()
33
+ player.replaceMediaItem(idx, updated)
34
+ return true
35
+ }
36
+ }
@@ -31,6 +31,15 @@ data class PlayerConfig(
31
31
  */
32
32
  val handleAudioBecomingNoisy: Boolean = true,
33
33
 
34
+ /**
35
+ * When true (default), stream-derived metadata (ICY/ID3) is written back into
36
+ * the queued MediaItem so getActiveMediaItem() and the system Now Playing
37
+ * info (notification, Bluetooth, Android Auto) follow the live title — even
38
+ * when the JS bundle isn't running. When false, only the MetadataReceived
39
+ * event fires; apps mutate the queue themselves via updateMetadata().
40
+ */
41
+ val autoUpdateMetadataFromStream: Boolean = true,
42
+
34
43
  /**
35
44
  * The wake mode to use for the player.
36
45
  */
@@ -188,6 +197,7 @@ data class PlayerConfig(
188
197
  return PlayerConfig(
189
198
  contentType = map.getString("contentType") ?: "music",
190
199
  handleAudioBecomingNoisy = if (map.hasKey("handleAudioBecomingNoisy")) map.getBoolean("handleAudioBecomingNoisy") else true,
200
+ autoUpdateMetadataFromStream = if (map.hasKey("autoUpdateMetadataFromStream")) map.getBoolean("autoUpdateMetadataFromStream") else true,
191
201
  wakeMode = when (android?.getString("wakeMode")) {
192
202
  "none" -> WakeMode.NONE
193
203
  "local" -> WakeMode.LOCAL
@@ -0,0 +1,98 @@
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.MediaMetadata
9
+ import androidx.media3.common.Metadata
10
+ import androidx.media3.common.util.UnstableApi
11
+ import androidx.media3.extractor.metadata.icy.IcyInfo
12
+
13
+ /**
14
+ * Pure helper that turns a media3 [Metadata] bundle (delivered via
15
+ * [androidx.media3.common.Player.Listener.onMetadata]) into a
16
+ * [MetadataReceivedEvent] suitable for emitting to JS.
17
+ *
18
+ * Lives outside [com.doublesymmetry.trackplayer.TrackPlayerModule] so it can be
19
+ * unit-tested without spinning up a player.
20
+ */
21
+ @UnstableApi
22
+ internal object StreamMetadataExtractor {
23
+
24
+ /**
25
+ * Returns a [MetadataReceivedEvent] aggregated from all entries in [metadata],
26
+ * or `null` if none of the entries contributed any user-visible fields.
27
+ *
28
+ * Behavior:
29
+ * - Each entry's [Metadata.Entry.populateMediaMetadata] is invoked. media3's
30
+ * built-in handlers cover ID3 ([androidx.media3.extractor.metadata.id3.TextInformationFrame]
31
+ * `TIT2`/`TPE1`/`TALB`/`TCON`/…), ICY ([IcyInfo] → title), Vorbis
32
+ * comments, MP4 MDTA, and more.
33
+ * - ICY's `StreamTitle` is conventionally formatted as `"Artist - Title"`
34
+ * (Shoutcast/Icecast practice, not a spec). When [populateMediaMetadata]
35
+ * leaves `artist` unset, we attempt to split on the first ` - ` to recover
36
+ * artist + title separately. Same heuristic V4 and the iOS engine use.
37
+ * - [IcyInfo.url] is mapped into `artworkUri` to mirror the iOS behavior of
38
+ * surfacing ICY `StreamUrl` (which some stations use to ship the current
39
+ * track's artwork).
40
+ */
41
+ fun extract(metadata: Metadata): MetadataReceivedEvent? {
42
+ val builder = MediaMetadata.Builder()
43
+ var icyUrl: String? = null
44
+ for (i in 0 until metadata.length()) {
45
+ val entry = metadata.get(i)
46
+ entry.populateMediaMetadata(builder)
47
+ if (entry is IcyInfo) {
48
+ entry.url?.takeIf { it.isNotEmpty() }?.let { icyUrl = it }
49
+ }
50
+ }
51
+ val built = builder.build()
52
+ var title = built.title?.toString()
53
+ var artist = built.artist?.toString()
54
+
55
+ if (artist == null && title != null) {
56
+ val (parsedTitle, parsedArtist) = parseStreamTitle(title!!)
57
+ title = parsedTitle
58
+ artist = parsedArtist
59
+ }
60
+
61
+ val albumTitle = built.albumTitle?.toString()
62
+ val artworkUri = built.artworkUri?.toString() ?: icyUrl
63
+ val genre = built.genre?.toString()
64
+
65
+ if (title == null && artist == null && albumTitle == null && artworkUri == null && genre == null) {
66
+ return null
67
+ }
68
+ return MetadataReceivedEvent(
69
+ title = title,
70
+ artist = artist,
71
+ albumTitle = albumTitle,
72
+ artworkUri = artworkUri,
73
+ genre = genre,
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Splits an ICY `StreamTitle` payload into `(title, artist)` using the
79
+ * de-facto Shoutcast convention `"Artist - Title"`.
80
+ *
81
+ * Returns `(trimmed, null)` when no ` - ` separator is present (e.g. talk
82
+ * radio with a single show title), and `(null, null)` for blank input.
83
+ * Only the first occurrence of ` - ` is treated as the separator, so a
84
+ * title containing additional `" - "` substrings (e.g. `"Artist - Song - Remix"`)
85
+ * yields `artist="Artist"`, `title="Song - Remix"`.
86
+ */
87
+ fun parseStreamTitle(raw: String): Pair<String?, String?> {
88
+ val trimmed = raw.trim()
89
+ if (trimmed.isEmpty()) return null to null
90
+ val sep = trimmed.indexOf(" - ")
91
+ if (sep >= 0) {
92
+ val artist = trimmed.substring(0, sep).trim()
93
+ val title = trimmed.substring(sep + 3).trim()
94
+ return title.ifEmpty { null } to artist.ifEmpty { null }
95
+ }
96
+ return trimmed to null
97
+ }
98
+ }
@@ -23,21 +23,23 @@ data class TrackPlayerMediaItem(
23
23
  val duration: Double? = null,
24
24
  val isLive: Boolean? = null,
25
25
  val mimeType: String? = null,
26
- val headers: Map<String, String>? = null
26
+ val headers: Map<String, String>? = null,
27
+ val extras: Bundle? = null
27
28
  ) {
28
29
  fun asMediaItem(): MediaItem {
29
30
  val id = mediaId ?: url
30
31
 
31
- val extras = Bundle()
32
- duration?.let { extras.putDouble("duration", it) }
33
- isLive?.let { extras.putBoolean("isLive", it) }
32
+ val metadataExtras = Bundle()
33
+ duration?.let { metadataExtras.putDouble("duration", it) }
34
+ isLive?.let { metadataExtras.putBoolean("isLive", it) }
35
+ extras?.let { metadataExtras.putBundle(EXTRAS_KEY, it) }
34
36
 
35
37
  val metadata = MediaMetadata.Builder()
36
38
  .setTitle(title)
37
39
  .setArtist(artist)
38
40
  .setAlbumTitle(albumTitle)
39
41
  .setArtworkUri(artworkUrl?.let { Uri.parse(it) })
40
- .setExtras(extras)
42
+ .setExtras(metadataExtras)
41
43
  .build()
42
44
 
43
45
  val requestExtras = Bundle()
@@ -81,10 +83,14 @@ data class TrackPlayerMediaItem(
81
83
  artworkUrl?.let { map.putString("artworkUrl", it) }
82
84
  duration?.let { map.putDouble("duration", it) }
83
85
  isLive?.let { map.putBoolean("isLive", it) }
86
+ extras?.let { map.putMap("extras", Arguments.fromBundle(it)) }
84
87
  return map
85
88
  }
86
89
 
87
90
  companion object {
91
+ /** Key under MediaMetadata.extras used to store the app-provided extras Bundle. */
92
+ private const val EXTRAS_KEY = "rntp.extras"
93
+
88
94
  private fun parseUrl(map: ReadableMap): Pair<String, Map<String, String>?> {
89
95
  if (!map.hasKey("url")) return "" to null
90
96
 
@@ -109,6 +115,10 @@ data class TrackPlayerMediaItem(
109
115
  fun fromReadableMap(map: ReadableMap): TrackPlayerMediaItem {
110
116
  val (url, headers) = parseUrl(map)
111
117
 
118
+ val extras = if (map.hasKey("extras")) {
119
+ map.getMap("extras")?.let { Arguments.toBundle(it) }
120
+ } else null
121
+
112
122
  return TrackPlayerMediaItem(
113
123
  mediaId = if (map.hasKey("mediaId")) map.getString("mediaId") else null,
114
124
  url = url,
@@ -120,12 +130,13 @@ data class TrackPlayerMediaItem(
120
130
  isLive = if (map.hasKey("isLive")) map.getBoolean("isLive") else null,
121
131
  mimeType = if (map.hasKey("mimeType")) map.getString("mimeType") else null,
122
132
  headers = headers,
133
+ extras = extras,
123
134
  )
124
135
  }
125
136
 
126
137
  fun fromMediaItem(mediaItem: MediaItem): TrackPlayerMediaItem {
127
138
  val metadata = mediaItem.mediaMetadata
128
- val extras = metadata.extras
139
+ val metadataExtras = metadata.extras
129
140
  return TrackPlayerMediaItem(
130
141
  mediaId = mediaItem.mediaId,
131
142
  url = mediaItem.localConfiguration?.uri?.toString() ?: mediaItem.mediaId,
@@ -133,8 +144,9 @@ data class TrackPlayerMediaItem(
133
144
  artist = metadata.artist?.toString(),
134
145
  albumTitle = metadata.albumTitle?.toString(),
135
146
  artworkUrl = metadata.artworkUri?.toString(),
136
- duration = extras?.getDouble("duration"),
137
- isLive = if (extras?.containsKey("isLive") == true) extras.getBoolean("isLive") else null,
147
+ duration = metadataExtras?.getDouble("duration"),
148
+ isLive = if (metadataExtras?.containsKey("isLive") == true) metadataExtras.getBoolean("isLive") else null,
149
+ extras = metadataExtras?.getBundle(EXTRAS_KEY),
138
150
  )
139
151
  }
140
152
  }
@@ -55,6 +55,39 @@ class EmitEventTest {
55
55
  assertEquals("title" to "Song", event.pairs()[0])
56
56
  }
57
57
 
58
+ @Test
59
+ fun `MediaMetadataChangedEvent uses media-metadata-changed event type`() {
60
+ val event = MediaMetadataChangedEvent(title = "Song", artist = "Artist")
61
+ assertEquals(EmitEventType.MEDIA_METADATA_CHANGED, event.type)
62
+ assertEquals("event.media-metadata-changed", event.type.value)
63
+ }
64
+
65
+ @Test
66
+ fun `MediaMetadataChangedEvent emits all set fields with normalized keys`() {
67
+ val event = MediaMetadataChangedEvent(
68
+ title = "Song",
69
+ artist = "Artist",
70
+ albumTitle = "Album",
71
+ artworkUri = "https://art",
72
+ genre = "Jazz",
73
+ )
74
+ val pairs = event.pairs().toMap()
75
+ assertEquals("Song", pairs["title"])
76
+ assertEquals("Artist", pairs["artist"])
77
+ assertEquals("Album", pairs["albumTitle"])
78
+ // artwork is published under the JS-facing `artworkUrl` key, matching
79
+ // the public MediaItem schema (not the Media3 `artworkUri`).
80
+ assertEquals("https://art", pairs["artworkUrl"])
81
+ assertEquals("Jazz", pairs["genre"])
82
+ }
83
+
84
+ @Test
85
+ fun `MediaMetadataChangedEvent only emits non-null fields`() {
86
+ val event = MediaMetadataChangedEvent(title = "Song", artist = null)
87
+ assertEquals(1, event.pairs().size)
88
+ assertEquals("title" to "Song", event.pairs()[0])
89
+ }
90
+
58
91
  @Test
59
92
  fun `QueueChangedEvent has empty pairs`() {
60
93
  assertEquals(0, QueueChangedEvent().pairs().size)
@@ -0,0 +1,224 @@
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
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [33])
25
+ class MetadataApplierTest {
26
+
27
+ private lateinit var player: ExoPlayer
28
+
29
+ @Before
30
+ fun setUp() {
31
+ val context = RuntimeEnvironment.getApplication()
32
+ player = ExoPlayer.Builder(context).build()
33
+ }
34
+
35
+ @After
36
+ fun tearDown() {
37
+ player.release()
38
+ }
39
+
40
+ private fun buildItem(
41
+ id: String,
42
+ title: String? = null,
43
+ artist: String? = null,
44
+ albumTitle: String? = null,
45
+ genre: String? = null,
46
+ ): MediaItem = MediaItem.Builder()
47
+ .setMediaId(id)
48
+ .setUri("https://example.com/$id.mp3")
49
+ .setMediaMetadata(
50
+ MediaMetadata.Builder().apply {
51
+ title?.let { setTitle(it) }
52
+ artist?.let { setArtist(it) }
53
+ albumTitle?.let { setAlbumTitle(it) }
54
+ genre?.let { setGenre(it) }
55
+ }.build()
56
+ )
57
+ .build()
58
+
59
+ // ---- Happy path: live ICY-style updates rewrite the current item ----
60
+
61
+ @Test
62
+ fun `applies title and artist to current item`() {
63
+ player.setMediaItems(listOf(buildItem("radio")))
64
+ val event = MetadataReceivedEvent(title = "Hoppípolla", artist = "Sigur Rós")
65
+
66
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
67
+
68
+ assertTrue(applied)
69
+ val updated = player.getMediaItemAt(0).mediaMetadata
70
+ assertEquals("Hoppípolla", updated.title?.toString())
71
+ assertEquals("Sigur Rós", updated.artist?.toString())
72
+ }
73
+
74
+ @Test
75
+ fun `preserves existing user-supplied fields the event does not touch`() {
76
+ player.setMediaItems(
77
+ listOf(
78
+ buildItem(
79
+ id = "radio",
80
+ title = "Static Station Title",
81
+ artist = null,
82
+ albumTitle = "My Radios",
83
+ genre = "Rock",
84
+ )
85
+ )
86
+ )
87
+ val event = MetadataReceivedEvent(title = "New Song", artist = "New Artist")
88
+
89
+ MetadataApplier.applyToCurrentMediaItem(player, event)
90
+
91
+ val updated = player.getMediaItemAt(0).mediaMetadata
92
+ assertEquals("New Song", updated.title?.toString())
93
+ assertEquals("New Artist", updated.artist?.toString())
94
+ assertEquals("My Radios", updated.albumTitle?.toString())
95
+ assertEquals("Rock", updated.genre?.toString())
96
+ }
97
+
98
+ @Test
99
+ fun `overwrites previous stream-supplied title on next update`() {
100
+ player.setMediaItems(listOf(buildItem("radio")))
101
+
102
+ MetadataApplier.applyToCurrentMediaItem(
103
+ player,
104
+ MetadataReceivedEvent(title = "Song A", artist = "Artist A"),
105
+ )
106
+ MetadataApplier.applyToCurrentMediaItem(
107
+ player,
108
+ MetadataReceivedEvent(title = "Song B", artist = "Artist B"),
109
+ )
110
+
111
+ val updated = player.getMediaItemAt(0).mediaMetadata
112
+ assertEquals("Song B", updated.title?.toString())
113
+ assertEquals("Artist B", updated.artist?.toString())
114
+ }
115
+
116
+ @Test
117
+ fun `null fields in event do not clobber existing values (sticky semantics)`() {
118
+ player.setMediaItems(listOf(buildItem(id = "radio", artist = "Existing Artist")))
119
+ val event = MetadataReceivedEvent(title = "Just a Title", artist = null)
120
+
121
+ MetadataApplier.applyToCurrentMediaItem(player, event)
122
+
123
+ val updated = player.getMediaItemAt(0).mediaMetadata
124
+ assertEquals("Just a Title", updated.title?.toString())
125
+ assertEquals("Existing Artist", updated.artist?.toString())
126
+ }
127
+
128
+ // ---- URI preserved → playback uninterrupted ----
129
+
130
+ @Test
131
+ fun `preserves URI on rewrite`() {
132
+ player.setMediaItems(listOf(buildItem("radio")))
133
+ val originalUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
134
+
135
+ MetadataApplier.applyToCurrentMediaItem(
136
+ player,
137
+ MetadataReceivedEvent(title = "Song", artist = "Artist"),
138
+ )
139
+
140
+ val newUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
141
+ assertEquals(originalUri, newUri)
142
+ }
143
+
144
+ @Test
145
+ fun `preserves mediaId on rewrite`() {
146
+ player.setMediaItems(listOf(buildItem("my-radio-id", title = "Original")))
147
+
148
+ MetadataApplier.applyToCurrentMediaItem(
149
+ player,
150
+ MetadataReceivedEvent(title = "Live Title", artist = null),
151
+ )
152
+
153
+ assertEquals("my-radio-id", player.getMediaItemAt(0).mediaId)
154
+ }
155
+
156
+ // ---- artworkUri / albumTitle / genre coverage ----
157
+
158
+ @Test
159
+ fun `applies artworkUri and genre from event`() {
160
+ player.setMediaItems(listOf(buildItem("radio")))
161
+ val event = MetadataReceivedEvent(
162
+ title = "Song",
163
+ artist = "Artist",
164
+ albumTitle = "Album",
165
+ artworkUri = "https://art.example.com/cover.jpg",
166
+ genre = "Indie",
167
+ )
168
+
169
+ MetadataApplier.applyToCurrentMediaItem(player, event)
170
+
171
+ val updated = player.getMediaItemAt(0).mediaMetadata
172
+ assertEquals("Album", updated.albumTitle?.toString())
173
+ assertEquals("https://art.example.com/cover.jpg", updated.artworkUri?.toString())
174
+ assertEquals("Indie", updated.genre?.toString())
175
+ }
176
+
177
+ // ---- No current item → no-op ----
178
+
179
+ @Test
180
+ fun `returns false when queue is empty`() {
181
+ val event = MetadataReceivedEvent(title = "Won't Apply", artist = null)
182
+
183
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
184
+
185
+ assertFalse(applied)
186
+ assertEquals(0, player.mediaItemCount)
187
+ }
188
+
189
+ // ---- Affects only the current index, not siblings ----
190
+
191
+ @Test
192
+ fun `only mutates the current item, not the rest of the queue`() {
193
+ player.setMediaItems(
194
+ listOf(
195
+ buildItem("track-1", title = "Title 1"),
196
+ buildItem("track-2", title = "Title 2"),
197
+ buildItem("track-3", title = "Title 3"),
198
+ )
199
+ )
200
+ val event = MetadataReceivedEvent(title = "Live Title 1", artist = null)
201
+
202
+ MetadataApplier.applyToCurrentMediaItem(player, event)
203
+
204
+ assertEquals("Live Title 1", player.getMediaItemAt(0).mediaMetadata.title?.toString())
205
+ assertEquals("Title 2", player.getMediaItemAt(1).mediaMetadata.title?.toString())
206
+ assertEquals("Title 3", player.getMediaItemAt(2).mediaMetadata.title?.toString())
207
+ }
208
+
209
+ // ---- Edge case: event with all null fields contributes nothing ----
210
+
211
+ @Test
212
+ fun `event with all null fields leaves item unchanged but still reports applied`() {
213
+ player.setMediaItems(listOf(buildItem(id = "radio", title = "Original Title")))
214
+ val event = MetadataReceivedEvent(
215
+ title = null, artist = null, albumTitle = null, artworkUri = null, genre = null,
216
+ )
217
+
218
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
219
+
220
+ assertTrue(applied)
221
+ assertEquals("Original Title", player.getMediaItemAt(0).mediaMetadata.title?.toString())
222
+ assertNull(player.getMediaItemAt(0).mediaMetadata.artist)
223
+ }
224
+ }