@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.
Files changed (61) 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 +43 -3
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +30 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +59 -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 +243 -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 +49 -2
  14. package/ios/models/EmitEvent.swift +28 -0
  15. package/ios/models/MediaItem.swift +8 -3
  16. package/ios/tests/AVPlayerEngineIntegrationTests.swift +13 -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 +9 -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 +9 -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 +31 -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 +8 -2
  47. package/lib/typescript/src/hooks/useActiveMediaItem.d.ts.map +1 -1
  48. package/lib/typescript/src/hooks/useProgress.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/MediaItem.d.ts +15 -0
  50. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +15 -0
  52. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/audio.ts +3 -0
  55. package/src/events/MediaMetadataChanged.ts +31 -0
  56. package/src/events/MetadataReceived.ts +15 -0
  57. package/src/events/index.ts +4 -0
  58. package/src/hooks/useActiveMediaItem.ts +9 -3
  59. package/src/hooks/useProgress.ts +1 -1
  60. package/src/interfaces/MediaItem.ts +15 -0
  61. package/src/interfaces/PlayerConfig.ts +16 -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,12 @@ 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
+ // Cached PlayerConfig.autoUpdateMetadataFromStream — refreshed in setupPlayer.
52
+ // When true, ICY/ID3 updates rewrite the queued MediaItem so Now Playing /
53
+ // getActiveMediaItem follow the live title even without JS. When false, we
54
+ // only emit MetadataReceived and let the app mutate the queue itself.
55
+ @Volatile private var autoUpdateMetadataFromStream: Boolean = true
46
56
 
47
57
  // endregion
48
58
 
@@ -60,6 +70,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
60
70
  override fun setupPlayer(map: ReadableMap) {
61
71
  val config = PlayerConfig.fromReadableMap(map)
62
72
  config.store(context)
73
+ autoUpdateMetadataFromStream = config.autoUpdateMetadataFromStream
63
74
 
64
75
  controller.run {
65
76
  val sessionToken = SessionToken(context, ComponentName(context, TrackPlayerPlaybackService::class.java))
@@ -636,6 +647,7 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
636
647
 
637
648
  override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
638
649
  lastStreamMetadata = null
650
+ lastMediaMetadata = null
639
651
  val mc = controller.get() ?: return
640
652
  val item = if (mediaItem != null) TrackPlayerMediaItem.fromMediaItem(mediaItem).toWritableMap() else null
641
653
  context.emitEvent(MediaItemTransitionEvent(item, mc.currentMediaItemIndex))
@@ -659,8 +671,12 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
659
671
  context.emitEvent(PlaybackStateChangedEvent(state))
660
672
  }
661
673
 
674
+ // Effective MediaItem metadata changed (queue transitioned, an
675
+ // updateMetadata call landed, or the auto-update path rewrote the active
676
+ // item). This is what backs `getActiveMediaItem()` and the system Now
677
+ // Playing info, so this is the surface most apps want to observe.
662
678
  override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
663
- val event = MetadataReceivedEvent(
679
+ val event = MediaMetadataChangedEvent(
664
680
  title = mediaMetadata.title?.toString(),
665
681
  artist = mediaMetadata.artist?.toString(),
666
682
  albumTitle = mediaMetadata.albumTitle?.toString(),
@@ -669,11 +685,35 @@ class TrackPlayerModule internal constructor(private val context: ReactApplicati
669
685
  )
670
686
  if (event.title == null && event.artist == null && event.albumTitle == null
671
687
  && event.artworkUri == null && event.genre == null) return
672
- if (event == lastStreamMetadata) return
673
- lastStreamMetadata = event
688
+ if (event == lastMediaMetadata) return
689
+ lastMediaMetadata = event
674
690
  context.emitEvent(event)
675
691
  }
676
692
 
693
+ // Raw stream-derived metadata (ICY blocks, ID3 frames, ...). Always emit
694
+ // the pre-merge view so consumers that need stream-truth (e.g. analytics
695
+ // or sanitization pipelines) see every frame.
696
+ //
697
+ // When `autoUpdateMetadataFromStream` is enabled (default), additionally
698
+ // rewrite the active MediaItem so the merged effective view picks up the
699
+ // change. That replaceMediaItem call triggers `onMediaMetadataChanged`,
700
+ // which then emits `MediaMetadataChanged` with the merged result —
701
+ // preserving any user-supplied fields (genre, album, etc.) the stream
702
+ // didn't include. The two events are intentionally separate: one for
703
+ // "what came in" (synchronous), one for "what the active item is now"
704
+ // (after the binder roundtrip lands).
705
+ override fun onMetadata(metadata: Metadata) {
706
+ val event = StreamMetadataExtractor.extract(metadata) ?: return
707
+ if (event != lastStreamMetadata) {
708
+ lastStreamMetadata = event
709
+ context.emitEvent(event)
710
+ }
711
+ if (autoUpdateMetadataFromStream) {
712
+ val mc = controller.get() ?: return
713
+ MetadataApplier.applyToCurrentMediaItem(mc, event)
714
+ }
715
+ }
716
+
677
717
  // endregion
678
718
 
679
719
  // 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,35 @@ data class MetadataReceivedEvent(
84
85
  }
85
86
  }
86
87
 
88
+ /**
89
+ * Carries the *effective* metadata of the currently active MediaItem after
90
+ * any merge between stream-derived metadata and user-supplied fields. Fires
91
+ * on track transitions, explicit updateMetadata calls, and (when
92
+ * autoUpdateMetadataFromStream is enabled) after the native side rewrites
93
+ * the active item in response to stream metadata.
94
+ *
95
+ * Mirrors Media3's Player.Listener.onMediaMetadataChanged callback.
96
+ */
97
+ data class MediaMetadataChangedEvent(
98
+ val title: String?,
99
+ val artist: String?,
100
+ val albumTitle: String? = null,
101
+ val artworkUri: String? = null,
102
+ val genre: String? = null,
103
+ ): EmitEvent {
104
+ override val type = EmitEventType.MEDIA_METADATA_CHANGED
105
+
106
+ override fun pairs(): Array<Pair<String, Any>> {
107
+ val result = mutableListOf<Pair<String, Any>>()
108
+ title?.let { result.add("title" to it) }
109
+ artist?.let { result.add("artist" to it) }
110
+ albumTitle?.let { result.add("albumTitle" to it) }
111
+ artworkUri?.let { result.add("artworkUrl" to it) }
112
+ genre?.let { result.add("genre" to it) }
113
+ return result.toTypedArray()
114
+ }
115
+ }
116
+
87
117
  class QueueChangedEvent : EmitEvent {
88
118
  override val type = EmitEventType.QUEUE_CHANGED
89
119
  override fun pairs(): Array<Pair<String, Any>> = arrayOf()
@@ -0,0 +1,59 @@
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
+ /**
13
+ * Pure helper that writes a stream-derived [MetadataReceivedEvent] back into
14
+ * the [Player]'s current MediaItem.
15
+ *
16
+ * Used by [com.doublesymmetry.trackplayer.TrackPlayerModule.onMetadata] when
17
+ * `PlayerConfig.autoUpdateMetadataFromStream` is enabled (the default), so
18
+ * that:
19
+ * - `getActiveMediaItem()` reflects the live ICY title.
20
+ * - `MPNowPlayingInfoCenter`'s Android analogue (Media3 session → system
21
+ * notification, Bluetooth, Android Auto) follows along.
22
+ *
23
+ * Lives outside [com.doublesymmetry.trackplayer.TrackPlayerModule] so it can
24
+ * be unit-tested against a real [androidx.media3.exoplayer.ExoPlayer] under
25
+ * Robolectric without spinning up a [androidx.media3.session.MediaController]
26
+ * + service.
27
+ */
28
+ internal object MetadataApplier {
29
+
30
+ /**
31
+ * Merges [event]'s non-null fields onto the current item's [MediaMetadata]
32
+ * and dispatches a [Player.replaceMediaItem]. The new MediaItem reuses the
33
+ * existing URI so Media3 keeps playback uninterrupted.
34
+ *
35
+ * @return `true` if the mutation was dispatched, `false` if there is no
36
+ * current item to update.
37
+ */
38
+ fun applyToCurrentMediaItem(player: Player, event: MetadataReceivedEvent): Boolean {
39
+ val idx = player.currentMediaItemIndex
40
+ if (idx < 0 || idx >= player.mediaItemCount) return false
41
+
42
+ val existing = player.getMediaItemAt(idx)
43
+ val newMeta = MediaMetadata.Builder()
44
+ .populate(existing.mediaMetadata)
45
+ .apply {
46
+ event.title?.let { setTitle(it) }
47
+ event.artist?.let { setArtist(it) }
48
+ event.albumTitle?.let { setAlbumTitle(it) }
49
+ event.artworkUri?.let { setArtworkUri(Uri.parse(it)) }
50
+ event.genre?.let { setGenre(it) }
51
+ }
52
+ .build()
53
+ val updated = existing.buildUpon()
54
+ .setMediaMetadata(newMeta)
55
+ .build()
56
+ player.replaceMediaItem(idx, updated)
57
+ return true
58
+ }
59
+ }
@@ -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)