Skip to content

Commit

Permalink
feature(metadata): expanded metadata support (#2118)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcvz authored Sep 19, 2023
1 parent 1bf0db1 commit 1333bfb
Show file tree
Hide file tree
Showing 19 changed files with 573 additions and 175 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package com.doublesymmetry.trackplayer.model

import android.os.Bundle
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.flac.VorbisComment
import com.google.android.exoplayer2.metadata.icy.IcyHeaders
import com.google.android.exoplayer2.metadata.icy.IcyInfo
import com.google.android.exoplayer2.metadata.id3.ChapterFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.id3.UrlLinkFrame
import com.google.android.exoplayer2.metadata.mp4.MdtaMetadataEntry
import timber.log.Timber

sealed class MetadataAdapter {
companion object {
fun fromMetadata(metadata: Metadata): List<Bundle> {
val group = mutableListOf<Bundle>()

(0 until metadata.length()).forEach { i ->
group.add(Bundle().apply {
val rawEntries = mutableListOf<Bundle>()

when (val entry = metadata[i]) {
is ChapterFrame -> {
Timber.d("ChapterFrame: ${entry.id}")
}
is TextInformationFrame -> {
val rawEntry = Bundle()

when (entry.id.uppercase()) {
"TIT2", "TT2" -> {
putString("title", entry.value)
rawEntry.putString("commonKey", "title")
}
"TALB", "TOAL", "TAL" -> {
putString("albumName", entry.value)
rawEntry.putString("commonKey", "albumName")
}
"TOPE", "TPE1", "TP1" -> {
putString("artist", entry.value)
rawEntry.putString("commonKey", "artist")
}
"TDRC", "TOR" -> {
putString("creationDate", entry.value)
rawEntry.putString("commonKey", "creationDate")
}
"TCON", "TCO" -> {
putString("genre", entry.value)
rawEntry.putString("commonKey", "genre")
}
}

rawEntry.putString("key", entry.id.uppercase())
rawEntry.putString("keySpace", "org.id3")
rawEntry.putString("value", entry.value)
rawEntry.putString("time", "-1")
rawEntries.add(rawEntry)
}

is UrlLinkFrame -> {
rawEntries.add(Bundle().apply {
putString("value", entry.url)
putString("key", entry.id.uppercase())
putString("keySpace", "org.id3")
putString("time", "-1")
})
}

is IcyHeaders -> {
putString("title", entry.name)
putString("genre", entry.genre)

rawEntries.add(Bundle().apply {
putString("value", entry.name)
putString("commonKey", "title")
putString("key", "StreamTitle")
putString("keySpace", "icy")
putString("time", "-1")
})

rawEntries.add(Bundle().apply {
putString("value", entry.url)
putString("key", "StreamURL")
putString("keySpace", "icy")
putString("time", "-1")
})

rawEntries.add(Bundle().apply {
putString("value", entry.genre)
putString("commonKey", "genre")
putString("key", "StreamGenre")
putString("keySpace", "icy")
putString("time", "-1")
})
}

is IcyInfo -> {
putString("title", entry.title)

rawEntries.add(Bundle().apply {
putString("value", entry.url)
putString("key", "StreamURL")
putString("keySpace", "icy")
putString("time", "-1")
})

rawEntries.add(Bundle().apply {
putString("value", entry.title)
putString("commonKey", "title")
putString("key", "StreamTitle")
putString("keySpace", "icy")
putString("time", "-1")
})
}

is VorbisComment -> {
val rawEntry = Bundle()

when (entry.key) {
"TITLE" -> {
putString("title", entry.value)
rawEntry.putString("commonKey", "title")
}
"ARTIST" -> {
putString("artist", entry.value)
rawEntry.putString("commonKey", "artist")
}
"ALBUM" -> {
putString("albumName", entry.value)
rawEntry.putString("commonKey", "albumName")
}
"DATE" -> {
putString("creationDate", entry.value)
rawEntry.putString("commonKey", "creationDate")
}
"GENRE" -> {
putString("genre", entry.value)
rawEntry.putString("commonKey", "genre")
}
"URL" -> {
putString("url", entry.value)
}
}

rawEntry.putString("key", entry.key)
rawEntry.putString("keySpace", "org.vorbis")
rawEntry.putString("value", entry.value)
rawEntry.putString("time", "-1")
rawEntries.add(rawEntry)
}

is MdtaMetadataEntry -> {
val rawEntry = Bundle()
when (entry.key) {
"com.apple.quicktime.title" -> {
putString("title", entry.value.toString())
rawEntry.putString("commonKey", "title")
}
"com.apple.quicktime.artist" -> {
putString("artist", entry.value.toString())
rawEntry.putString("commonKey", "artist")
}
"com.apple.quicktime.album" -> {
putString("albumName", entry.value.toString())
rawEntry.putString("commonKey", "albumName")
}
"com.apple.quicktime.creationdate" -> {
putString("creationDate", entry.value.toString())
rawEntry.putString("commonKey", "creationDate")
}
"com.apple.quicktime.genre" -> {
putString("genre", entry.value.toString())
rawEntry.putString("commonKey", "genre")
}
}

rawEntry.putString("key", entry.key.substringAfterLast("."))
rawEntry.putString("keySpace", "com.apple.quicktime")
rawEntry.putString("value", entry.value.toString())
rawEntry.putString("time", "-1")
rawEntries.add(rawEntry)
}
}

putParcelableArray("raw", rawEntries.toTypedArray())
})
}

return group
}

fun fromMediaMetadata(metadata: MediaMetadata): Bundle {
return Bundle().apply {
metadata.title?.let { putString("title", it.toString()) }
metadata.artist?.let { putString("artist", it.toString()) }
metadata.albumTitle?.let { putString("albumName", it.toString()) }
metadata.subtitle?.let { putString("subtitle", it.toString()) }
metadata.description?.let { putString("description", it.toString()) }
metadata.artworkUri?.let { putString("artworkUri", it.toString()) }
metadata.trackNumber?.let { putInt("trackNumber", it) }
metadata.composer?.let { putString("composer", it.toString()) }
metadata.conductor?.let { putString("conductor", it.toString()) }
metadata.genre?.let { putString("genre", it.toString()) }
metadata.compilation?.let { putString("compilation", it.toString()) }
metadata.station?.let { putString("station", it.toString()) }
metadata.mediaType?.let { putInt("mediaType", it) }

// This is how SwiftAudioEx outputs it in the metadata dictionary
(metadata.recordingDay to metadata.recordingMonth).let { (day, month) ->
// if both are not null, combine them into a single string
if (day != null && month != null) {
putString("creationDate", "${String.format("%02d", day)}${String.format("%02d", month)}")
} else if (day != null) {
putString("creationDate", String.format("%02d", day))
} else if (month != null) {
putString("creationDate", String.format("%02d", month))
}
}
metadata.recordingYear?.let { putString("creationYear", it.toString()) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class MusicEvents(private val reactContext: ReactContext) : BroadcastReceiver()
const val PLAYBACK_PROGRESS_UPDATED = "playback-progress-updated"
const val PLAYBACK_ERROR = "playback-error"

// Metadata Events
const val METADATA_CHAPTER_RECEIVED = "metadata-chapter-received"
const val METADATA_TIMED_RECEIVED = "metadata-timed-received"
const val METADATA_COMMON_RECEIVED = "metadata-common-received"

// Other
const val PLAYER_ERROR = "player-error"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import com.doublesymmetry.trackplayer.model.Track
import com.doublesymmetry.trackplayer.module.MusicEvents.Companion.EVENT_INTENT
import com.doublesymmetry.trackplayer.service.MusicService
import com.doublesymmetry.trackplayer.utils.AppForegroundTracker
import com.doublesymmetry.trackplayer.utils.BundleUtils
import com.doublesymmetry.trackplayer.utils.RejectionException
import com.facebook.react.bridge.*
import com.google.android.exoplayer2.DefaultLoadControl.*
Expand Down Expand Up @@ -292,7 +291,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
callback.resolve(null)
return@launch
}
var bundle = Arguments.toBundle(data);
val bundle = Arguments.toBundle(data);
if (bundle is Bundle) {
musicService.load(bundleToTrack(bundle))
callback.resolve(null)
Expand All @@ -314,7 +313,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
val inputIndexes = Arguments.toList(data)
if (inputIndexes != null) {
val size = musicService.tracks.size
var indexes: ArrayList<Int> = ArrayList();
val indexes: ArrayList<Int> = ArrayList();
for (inputIndex in inputIndexes) {
val index = if (inputIndex is Int) inputIndex else inputIndex.toString().toInt()
if (index < 0 || index >= size) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import android.support.v4.media.RatingCompat
import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.PRIORITY_LOW
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.doublesymmetry.kotlinaudio.models.*
import com.doublesymmetry.kotlinaudio.models.NotificationButton.*
import com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer
Expand All @@ -22,17 +21,17 @@ import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toMilliseco
import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toSeconds
import com.doublesymmetry.trackplayer.extensions.asLibState
import com.doublesymmetry.trackplayer.extensions.find
import com.doublesymmetry.trackplayer.model.MetadataAdapter
import com.doublesymmetry.trackplayer.model.PlaybackMetadata
import com.doublesymmetry.trackplayer.model.Track
import com.doublesymmetry.trackplayer.model.TrackAudioItem
import com.doublesymmetry.trackplayer.module.MusicEvents
import com.doublesymmetry.trackplayer.module.MusicEvents.Companion.EVENT_INTENT
import com.doublesymmetry.trackplayer.utils.AppForegroundTracker
import com.doublesymmetry.trackplayer.utils.BundleUtils
import com.doublesymmetry.trackplayer.utils.BundleUtils.setRating
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.google.android.exoplayer2.ui.R as ExoPlayerR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.flow
Expand Down Expand Up @@ -459,7 +458,7 @@ class MusicService : HeadlessJsTaskService() {

val b = Bundle()
b.putDouble("lastPosition", oldPosition)
if (tracks.size > 0) {
if (tracks.isNotEmpty()) {
b.putInt("index", player.currentIndex)
b.putBundle("track", tracks[player.currentIndex].originalItem)
if (previousIndex != null) {
Expand Down Expand Up @@ -662,6 +661,9 @@ class MusicService : HeadlessJsTaskService() {

scope.launch {
event.onTimedMetadata.collect {
val data = MetadataAdapter.fromMetadata(it)
emitList(MusicEvents.METADATA_TIMED_RECEIVED, data)

// TODO: Handle the different types of metadata and publish to new events
val metadata = PlaybackMetadata.fromId3Metadata(it)
?: PlaybackMetadata.fromIcy(it)
Expand All @@ -683,6 +685,13 @@ class MusicService : HeadlessJsTaskService() {
}
}

scope.launch {
event.onCommonMetadata.collect {
val data = MetadataAdapter.fromMediaMetadata(it)
emit(MusicEvents.METADATA_COMMON_RECEIVED, data)
}
}

scope.launch {
event.playWhenReadyChange.collect {
Bundle().apply {
Expand Down Expand Up @@ -712,11 +721,20 @@ class MusicService : HeadlessJsTaskService() {
}

@MainThread
private fun emit(event: String?, data: Bundle? = null) {
val intent = Intent(EVENT_INTENT)
intent.putExtra(EVENT_KEY, event)
if (data != null) intent.putExtra(DATA_KEY, data)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
private fun emit(event: String, data: Bundle? = null) {
reactNativeHost.reactInstanceManager.currentReactContext
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, data?.let { Arguments.fromBundle(it) })
}

@MainThread
private fun emitList(event: String, data: List<Bundle> = emptyList()) {
val payload = Arguments.createArray()
data.forEach { payload.pushMap(Arguments.fromBundle(it)) }

reactNativeHost.reactInstanceManager.currentReactContext
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit(event, payload)
}

override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig {
Expand Down
21 changes: 21 additions & 0 deletions docs/docs/api/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Fired when the queue reaches the end.
| position | `number` | The previous track position in seconds |

### `PlaybackMetadataReceived`

**⚠️ Deprecated:** Please use `AudioChapterMetadataReceived`, `AudioTimedMetadataReceived`, `AudioCommonMetadataReceived`.

Fired when the current track receives metadata encoded in. (e.g. ID3 tags, Icy Metadata, Vorbis Comments or QuickTime metadata).

| Param | Type | Description |
Expand Down Expand Up @@ -199,3 +202,21 @@ event in the following situations:
| --------- | --------- | -------------------------------------------- |
| paused | `boolean` | On Android when `true` the player should pause playback, when `false` the player may resume playback. On iOS when `true` the playback was paused and when `false` the player may resume playback. |
| permanent | `boolean` | Whether the interruption is permanent. On Android the player should stop playback. |


## Metadata

### `AudioCommonMetadataReceived`
Fired when the current track receives metadata encoded in - static metadata not tied to a time. Usually received at start.

Received data will be [`AudioCommonMetadataReceivedEvent`](./api/objects/metadata.md) - `raw` will always be `undefined`.

### `AudioTimedMetadataReceived`
Fired when the current track receives metadata encoded in - dynamic metadata tied to a time. Events may be emitted over time.

Received data will be [`AudioTimedMetadataReceivedEvent`](./api/objects/metadata.md).

### `AudioChapterMetadataReceived` (iOS only)
Fired when the current track receives metadata encoded in - chapter overview data. Usually received at start.

Received data will be [`AudioChapterMetadataReceivedEvent`](./api/objects/metadata.md).
Loading

0 comments on commit 1333bfb

Please sign in to comment.