Skip to content

Instantly share code, notes, and snippets.

@silverAndroid
Last active August 24, 2025 13:01
Show Gist options
  • Save silverAndroid/7471a7ad60ad765d8dda917272a3bf04 to your computer and use it in GitHub Desktop.
Save silverAndroid/7471a7ad60ad765d8dda917272a3bf04 to your computer and use it in GitHub Desktop.
How I got subtitles to work with CastPlayer
/** Copied [DefaultMediaItemConverter] but with subtitle support. */
@OptIn(UnstableApi::class)
class CastMediaItemConverter : MediaItemConverter {
private val KEY_MEDIA_ITEM = "mediaItem"
private val KEY_PLAYER_CONFIG = "exoPlayerConfig"
private val KEY_MEDIA_ID = "mediaId"
private val KEY_URI = "uri"
private val KEY_TITLE = "title"
private val KEY_MIME_TYPE = "mimeType"
private val KEY_DRM_CONFIGURATION = "drmConfiguration"
private val KEY_UUID = "uuid"
private val KEY_LICENSE_URI = "licenseUri"
private val KEY_REQUEST_HEADERS = "requestHeaders"
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
val mediaInfo = mediaQueueItem.media
Assertions.checkNotNull(mediaInfo)
val metadataBuilder = androidx.media3.common.MediaMetadata.Builder()
val metadata = mediaInfo!!.metadata
if (metadata != null) {
if (metadata.containsKey(MediaMetadata.KEY_TITLE)) {
metadataBuilder.setTitle(metadata.getString(MediaMetadata.KEY_TITLE))
}
if (metadata.containsKey(MediaMetadata.KEY_SUBTITLE)) {
metadataBuilder.setSubtitle(metadata.getString(MediaMetadata.KEY_SUBTITLE))
}
if (metadata.containsKey(MediaMetadata.KEY_ARTIST)) {
metadataBuilder.setArtist(metadata.getString(MediaMetadata.KEY_ARTIST))
}
if (metadata.containsKey(MediaMetadata.KEY_ALBUM_ARTIST)) {
metadataBuilder.setAlbumArtist(metadata.getString(MediaMetadata.KEY_ALBUM_ARTIST))
}
if (metadata.containsKey(MediaMetadata.KEY_ALBUM_TITLE)) {
metadataBuilder.setAlbumTitle(metadata.getString(MediaMetadata.KEY_ALBUM_TITLE))
}
if (metadata.images.isNotEmpty()) {
metadataBuilder.setArtworkUri(metadata.images[0].url)
}
if (metadata.containsKey(MediaMetadata.KEY_COMPOSER)) {
metadataBuilder.setComposer(metadata.getString(MediaMetadata.KEY_COMPOSER))
}
if (metadata.containsKey(MediaMetadata.KEY_DISC_NUMBER)) {
metadataBuilder.setDiscNumber(metadata.getInt(MediaMetadata.KEY_DISC_NUMBER))
}
if (metadata.containsKey(MediaMetadata.KEY_TRACK_NUMBER)) {
metadataBuilder.setTrackNumber(metadata.getInt(MediaMetadata.KEY_TRACK_NUMBER))
}
}
// `mediaQueueItem` came from `toMediaQueueItem()` so the custom JSON data must be set.
return getMediaItem(
Assertions.checkNotNull(mediaInfo.customData),
metadataBuilder.build(),
mediaInfo.mediaTracks,
)
}
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
Assertions.checkNotNull(mediaItem.localConfiguration)
requireNotNull(mediaItem.localConfiguration!!.mimeType) { "The item must specify its mimeType" }
val metadata =
MediaMetadata(
if (MimeTypes.isAudio(mediaItem.localConfiguration!!.mimeType))
MediaMetadata.MEDIA_TYPE_MUSIC_TRACK
else MediaMetadata.MEDIA_TYPE_MOVIE
)
if (mediaItem.mediaMetadata.title != null) {
metadata.putString(MediaMetadata.KEY_TITLE, mediaItem.mediaMetadata.title.toString())
}
if (mediaItem.mediaMetadata.subtitle != null) {
metadata.putString(MediaMetadata.KEY_SUBTITLE, mediaItem.mediaMetadata.subtitle.toString())
}
if (mediaItem.mediaMetadata.artist != null) {
metadata.putString(MediaMetadata.KEY_ARTIST, mediaItem.mediaMetadata.artist.toString())
}
if (mediaItem.mediaMetadata.albumArtist != null) {
metadata.putString(
MediaMetadata.KEY_ALBUM_ARTIST,
mediaItem.mediaMetadata.albumArtist.toString(),
)
}
if (mediaItem.mediaMetadata.albumTitle != null) {
metadata.putString(
MediaMetadata.KEY_ALBUM_TITLE,
mediaItem.mediaMetadata.albumTitle.toString(),
)
}
if (mediaItem.mediaMetadata.artworkUri != null) {
metadata.addImage(WebImage(mediaItem.mediaMetadata.artworkUri!!))
}
if (mediaItem.mediaMetadata.composer != null) {
metadata.putString(MediaMetadata.KEY_COMPOSER, mediaItem.mediaMetadata.composer.toString())
}
if (mediaItem.mediaMetadata.discNumber != null) {
metadata.putInt(MediaMetadata.KEY_DISC_NUMBER, mediaItem.mediaMetadata.discNumber!!)
}
if (mediaItem.mediaMetadata.trackNumber != null) {
metadata.putInt(MediaMetadata.KEY_TRACK_NUMBER, mediaItem.mediaMetadata.trackNumber!!)
}
val contentUrl = mediaItem.localConfiguration!!.uri.toString()
val contentId =
if (mediaItem.mediaId == MediaItem.DEFAULT_MEDIA_ID) contentUrl else mediaItem.mediaId
val mediaInfo =
MediaInfo.Builder(contentId)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(mediaItem.localConfiguration!!.mimeType)
.setContentUrl(contentUrl)
.setMetadata(metadata)
.setMediaTracks(
mediaItem.localConfiguration!!.subtitleConfigurations.mapIndexed {
index,
subtitleConfiguration ->
MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT)
.setLanguage(subtitleConfiguration.language)
.setName(subtitleConfiguration.label)
.setContentId(subtitleConfiguration.uri.toString())
.build()
}
)
.setCustomData(getCustomData(mediaItem))
.build()
return MediaQueueItem.Builder(mediaInfo).build()
}
// Deserialization.
private fun getMediaItem(
customData: JSONObject,
mediaMetadata: androidx.media3.common.MediaMetadata,
mediaTracks: List<MediaTrack>?,
): MediaItem {
try {
val mediaItemJson = customData.getJSONObject(KEY_MEDIA_ITEM)
val builder =
MediaItem.Builder()
.setUri(Uri.parse(mediaItemJson.getString(KEY_URI)))
.setMediaId(mediaItemJson.getString(KEY_MEDIA_ID))
.setMediaMetadata(mediaMetadata)
if (mediaItemJson.has(KEY_MIME_TYPE)) {
builder.setMimeType(mediaItemJson.getString(KEY_MIME_TYPE))
}
if (mediaItemJson.has(KEY_DRM_CONFIGURATION)) {
populateDrmConfiguration(mediaItemJson.getJSONObject(KEY_DRM_CONFIGURATION), builder)
}
if (mediaTracks != null) {
builder.setSubtitleConfigurations(
mediaTracks.map {
SubtitleConfiguration.Builder(Uri.parse(it.contentId))
.setLanguage(it.language)
.setMimeType(MimeTypes.TEXT_VTT)
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build()
}
)
}
return builder.build()
} catch (e: JSONException) {
throw RuntimeException(e)
}
}
@Throws(JSONException::class)
private fun populateDrmConfiguration(json: JSONObject, mediaItem: MediaItem.Builder) {
val drmConfiguration =
DrmConfiguration.Builder(UUID.fromString(json.getString(KEY_UUID)))
.setLicenseUri(json.getString(KEY_LICENSE_URI))
val requestHeadersJson = json.getJSONObject(KEY_REQUEST_HEADERS)
val requestHeaders = HashMap<String, String>()
val iterator = requestHeadersJson.keys()
while (iterator.hasNext()) {
val key = iterator.next()
requestHeaders[key] = requestHeadersJson.getString(key)
}
drmConfiguration.setLicenseRequestHeaders(requestHeaders)
mediaItem.setDrmConfiguration(drmConfiguration.build())
}
// Serialization.
private fun getCustomData(mediaItem: MediaItem): JSONObject {
val json = JSONObject()
try {
json.put(KEY_MEDIA_ITEM, getMediaItemJson(mediaItem))
val playerConfigJson = getPlayerConfigJson(mediaItem)
if (playerConfigJson != null) {
json.put(KEY_PLAYER_CONFIG, playerConfigJson)
}
} catch (e: JSONException) {
throw RuntimeException(e)
}
return json
}
@Throws(JSONException::class)
private fun getMediaItemJson(mediaItem: MediaItem): JSONObject {
Assertions.checkNotNull(mediaItem.localConfiguration)
val json = JSONObject()
json.put(KEY_MEDIA_ID, mediaItem.mediaId)
json.put(KEY_TITLE, mediaItem.mediaMetadata.title)
json.put(KEY_URI, mediaItem.localConfiguration!!.uri.toString())
json.put(KEY_MIME_TYPE, mediaItem.localConfiguration!!.mimeType)
if (mediaItem.localConfiguration!!.drmConfiguration != null) {
json.put(
KEY_DRM_CONFIGURATION,
getDrmConfigurationJson(mediaItem.localConfiguration!!.drmConfiguration),
)
}
return json
}
@Throws(JSONException::class)
private fun getDrmConfigurationJson(drmConfiguration: DrmConfiguration?): JSONObject {
val json = JSONObject()
json.put(KEY_UUID, drmConfiguration!!.scheme)
json.put(KEY_LICENSE_URI, drmConfiguration.licenseUri)
json.put(KEY_REQUEST_HEADERS, JSONObject(drmConfiguration.licenseRequestHeaders as Map<*, *>?))
return json
}
@Throws(JSONException::class)
private fun getPlayerConfigJson(mediaItem: MediaItem): JSONObject? {
if (
mediaItem.localConfiguration == null ||
mediaItem.localConfiguration!!.drmConfiguration == null
) {
return null
}
val drmConfiguration = mediaItem.localConfiguration!!.drmConfiguration
val drmScheme =
if (C.WIDEVINE_UUID == drmConfiguration!!.scheme) {
"widevine"
} else if (C.PLAYREADY_UUID == drmConfiguration.scheme) {
"playready"
} else {
return null
}
val playerConfigJson = JSONObject()
playerConfigJson.put("withCredentials", false)
playerConfigJson.put("protectionSystem", drmScheme)
if (drmConfiguration.licenseUri != null) {
playerConfigJson.put("licenseUrl", drmConfiguration.licenseUri)
}
if (!drmConfiguration.licenseRequestHeaders.isEmpty()) {
playerConfigJson.put(
"headers",
JSONObject(drmConfiguration.licenseRequestHeaders as Map<*, *>?),
)
}
return playerConfigJson
}
}
// run this code once the video starts playing
val remoteMediaClient = castContext!!.sessionManager.currentCastSession!!.remoteMediaClient!!
val mediaTracks = remoteMediaClient.mediaInfo!!.mediaTracks!!
val subtitleTrack = mediaTracks.firstOrNull { it.type == (MediaTrack.TYPE_TEXT) }
val videoTrack = mediaTracks.firstOrNull { it.type == (MediaTrack.TYPE_VIDEO) }
val activeTrackIds = listOfNotNull(subtitleTrack?.id, videoTrack?.id).toLongArray()
remoteMediaClient.setActiveMediaTracks(activeTrackIds)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment