@rntp/player 5.0.0-beta.6 → 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
package/android/src/main/java/com/doublesymmetry/trackplayer/HeaderInjectingDataSourceFactory.kt
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 ==
|
|
673
|
-
|
|
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
|
package/android/src/main/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractor.kt
ADDED
|
@@ -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
|
|
32
|
-
duration?.let {
|
|
33
|
-
isLive?.let {
|
|
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(
|
|
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
|
|
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 =
|
|
137
|
-
isLive = if (
|
|
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)
|