Search code examples
android-mediaplayerexoplayerandroid-mediasessionandroid-media3exoplayer-media-item

Android Media3: How to split HLS stream into multiple mediaitems each with own range


I want to split an HLS audio stream into chapters and add them as a list of MediaItems to the Media3 player using MediaSessionService. I have this following code in MediaController.

From the composable screen:

fun getMediaItems(uri: String, chapters: List<Chapter>, token: String): List<MediaItem> {
    val requestMetaData = MediaItem.RequestMetadata.Builder()
        .setExtras(bundleOf( "TOKEN" to token,)) .build()

    var offsetInMs = 0L

    return chapters.map { chapter ->
        val mediaMetaData = MediaMetadata.Builder()
            .setTitle(chapter.title)
            .build()
        val clippingConfiguration = ClippingConfiguration.Builder()
            .setStartPositionMs(offsetInMs)
            .setEndPositionMs(offsetInMs + chapter.duration)
            .setRelativeToDefaultPosition(true)
            .build()
        offsetInMs += chapter.runtime

        MediaItem.Builder()
            .setMediaId(chapter.id)
            .setUri(uri)
            .setMimeType(MimeTypes.APPLICATION_M3U8)
            .setClippingConfiguration(clippingConfiguration)
            .setRequestMetadata(requestMetaData)
            .setMediaMetadata(mediaMetaData)
            .build()
    }
}

From ViewModel:

var mediaControllerFuture = MutableStateFlow<ListenableFuture<MediaController>?>(null)


fun prepare(context: Context, mediaItems: List<MediaItem>) {
    val sessionToken = SessionToken(
        context,
        ComponentName(context, PlaybackService::class.java),
    )
    MediaController.Builder(context, sessionToken)
        .buildAsync()
        .also {
            val listener = Runnable {
                mediaControllerFuture.value?.get()?.let { controller ->
                    controller.addListener(listener)
                    controller.addMediaItems(mediaItems)
                    controller.prepare()
                    controller.play()
                }
            }
            mediaControllerFuture.value = it
            mediaControllerFuture.value?.addListener(listener, MoreExecutors.directExecutor())
        } 
}

Service:

class PlaybackService : MediaSessionService() {
    private var mediaSession: MediaSession? = null

    override fun onCreate() {
        super.onCreate()
        val customFactory = object : MediaSource.Factory {
            override fun createMediaSource(mediaItem: MediaItem): MediaSource {
                val token = mediaItem.requestMetadata.extras?.getString("TOKEN") ?: ""
                val dataSourceFactory = DataSource.Factory {
                    val dataSource = DefaultHttpDataSource.Factory().createDataSource()
                    dataSource.setRequestProperty("Authorization", token)
                    dataSource
                }
                return HlsMediaSource.Factory(dataSourceFactory)
                    .createMediaSource(mediaItem)
            }
        }
        val player = ExoPlayer.Builder(this)
            .setMediaSourceFactory(customFactory)
            .build()
            .apply {
                playWhenReady = true
            }

        mediaSession = MediaSession.Builder(this, player)
            .build()
    }

    override fun onTaskRemoved(rootIntent: Intent?) { ... }

    override fun onDestroy() { ... }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
        return mediaSession
    }
}

However, the player is still playing the entire stream (ignoring start/end positions) and doesn't look like it is moving to the next MediaItem. Or, if it is indeed advancing to subsequent MediaItems how can I find when the player actually advances (so that I can update the player UI to indicate the current chapter being played)? Are there any listener events I can track?

I tried this callback, but never saw it being invoked except once at the very beginning.

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int)

Solution

  • Turned out to be pretty simple. Need to wrap the media source with ClippingMediaSource like below.

                override fun createMediaSource(mediaItem: MediaItem): MediaSource {
                    val token = mediaItem.requestMetadata.extras?.getString("TOKEN") ?: "" 
                    val dataSourceFactory = DataSource.Factory {
                        val dataSource = DefaultHttpDataSource.Factory().createDataSource()
                        dataSource.setRequestProperty("Authorization", token)
                        dataSource
                    }
    
                    val hlsMediaSource = HlsMediaSource.Factory(dataSourceFactory)
                        .createMediaSource(mediaItem)
    
                    return ClippingMediaSource(
                        hlsMediaSource,
                        mediaItem.clippingConfiguration.startPositionUs,
                        mediaItem.clippingConfiguration.endPositionUs,
                        /* enableInitialDiscontinuity= */
                        !mediaItem.clippingConfiguration.startsAtKeyFrame,
                        /* allowDynamicClippingUpdates= */
                        mediaItem.clippingConfiguration.relativeToLiveWindow,
                        mediaItem.clippingConfiguration.relativeToDefaultPosition,
                    )
                }