@rntp/player 5.0.0 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/doublesymmetry/trackplayer/HeaderInjectingDataSourceFactory.kt +5 -1
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +23 -3
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +22 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +36 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +10 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractor.kt +98 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItem.kt +20 -8
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +33 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/MetadataApplierTest.kt +224 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +33 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractorTest.kt +254 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +202 -0
- package/ios/TrackPlayer.swift +28 -2
- package/ios/models/EmitEvent.swift +28 -0
- package/ios/models/MediaItem.swift +8 -3
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +7 -4
- package/lib/commonjs/audio.js +1 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/events/MediaMetadataChanged.js +2 -0
- package/lib/commonjs/events/MediaMetadataChanged.js.map +1 -0
- package/lib/commonjs/events/index.js +13 -0
- package/lib/commonjs/events/index.js.map +1 -1
- package/lib/commonjs/hooks/useActiveMediaItem.js +3 -3
- package/lib/commonjs/hooks/useActiveMediaItem.js.map +1 -1
- package/lib/commonjs/hooks/useProgress.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -0
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/audio.js +1 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/events/MediaMetadataChanged.js +2 -0
- package/lib/module/events/MediaMetadataChanged.js.map +1 -0
- package/lib/module/events/index.js +2 -0
- package/lib/module/events/index.js.map +1 -1
- package/lib/module/hooks/useActiveMediaItem.js +3 -3
- package/lib/module/hooks/useActiveMediaItem.js.map +1 -1
- package/lib/module/hooks/useProgress.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -0
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/events/MediaMetadataChanged.d.ts +18 -0
- package/lib/typescript/src/events/MediaMetadataChanged.d.ts.map +1 -0
- package/lib/typescript/src/events/MetadataReceived.d.ts +15 -0
- package/lib/typescript/src/events/MetadataReceived.d.ts.map +1 -1
- package/lib/typescript/src/events/index.d.ts +4 -0
- package/lib/typescript/src/events/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useActiveMediaItem.d.ts +2 -2
- package/lib/typescript/src/hooks/useProgress.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +15 -0
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +8 -0
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/events/MediaMetadataChanged.ts +18 -0
- package/src/events/MetadataReceived.ts +15 -0
- package/src/events/index.ts +4 -0
- package/src/hooks/useActiveMediaItem.ts +3 -3
- package/src/hooks/useProgress.ts +1 -1
- package/src/interfaces/MediaItem.ts +15 -0
- package/src/interfaces/PlayerConfig.ts +9 -0
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,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 =
|
|
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 ==
|
|
673
|
-
|
|
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
|
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)
|
|
@@ -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
|
+
}
|