media icon indicating copy to clipboard operation
media copied to clipboard

Add rememberMediaController to be in line with the new compose functions in media3

Open at-oliverapps opened this issue 2 months ago • 2 comments

this is my first real and serious contribution on GitHub so forgive me if its not perfect

[REQUIRED] Use case description

you start by removing all your old boilerplate garbage you might have laying around then you just simply write val mediaController = rememberMediaController<PlaybackService>() thats all you now have a connection to your service and you can start using it if you need the non null player the compose coomponents need you can simply write

mediaController?.let { //here you have the non null version of the player so you can put a fullscreen player or a playbar into the app }

here is some code to get you started and to show the use case

The playback service simplyfied

@UnstableApi
class PlaybackService : MediaSessionService() {

    private lateinit var mediaSession: MediaSession

    private val player: ExoPlayer by lazy {
        ExoPlayer.Builder(this).build()
            .apply {
                
                // this isn't necessary its just there so you can seek through the playing contents of a station faster
                setSeekBackIncrementMs( 30*1000L )
                setSeekForwardIncrementMs( 30*1000L )
                
                setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
                setHandleAudioBecomingNoisy(true)
                setWakeMode(WAKE_MODE_NETWORK)
                
            }
    }

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

    override fun onCreate() {
        super.onCreate()

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

        player.run {
            addMediaItems(
                listOf(
                    MediaItem.Builder().setMediaId("1").setUri("[http://nebula.shoutca.st:8545/stream](http://nebula.shoutca.st:8545/stream)").setMediaMetadata(MediaMetadata.Builder().setTitle("ZFM").setArtworkUri("[https://nz.radio.net/300/zfm.png?version=a00d95bdda87861f1584dc30caffb0f9](https://nz.radio.net/300/zfm.png?version=a00d95bdda87861f1584dc30caffb0f9)".toUri()).build()).build(),
                    MediaItem.Builder().setMediaId("2").setUri("[https://live.visir.is/hls-radio/fm957/chunklist_DVR.m3u8](https://live.visir.is/hls-radio/fm957/chunklist_DVR.m3u8)").setMediaMetadata(MediaMetadata.Builder().setTitle("FM 957").setArtworkUri("[https://www.visir.is/mi/300x300/ci/ef50c5c5-6abf-4dfe-910c-04d88b6bdaef.png](https://www.visir.is/mi/300x300/ci/ef50c5c5-6abf-4dfe-910c-04d88b6bdaef.png)".toUri()).build()).build()
                )
            )
        }

    }

    override fun onDestroy() {
        super.onDestroy()
        mediaSession.run {
            release()
            player.stop()
            player.release()
        }
    }
}

Your typical MainActivity or the class hosting your very root composable

class YourMainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            //you just init your composable as usual no extra ordinary is required for the mediaController to function
            App()
        }
    }
}

Your Main App() composable

@SuppressLint("UnsafeOptInUsageError")
@Composable
fun App() {

    //The magic starts here, this is your bridge between your service and the ui replace "PlayerService" with either an extension of "MediaLibraryService" or a "MediaSessionService"
    val mediaController by rememberMediaController<PlaybackService>()

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(title = { Text("List Of Media") })
        },
        content = {
            LazyColumn(contentPadding = it) {

                itemsIndexed(mediaController?.mediaItems ?: emptyList()) { index, mediaItem ->
                    ListItem(
                        modifier = Modifier.clickable {
                            /*
                            ideally you would use
                            mediaController?.run {
                                clearMediaItems()
                                addMediaItems(mediaItems)
                                val index = mediaItems.indexOfFirst { it.mediaId == mediaItem.mediaId }
                                if (index != -1) {
                                    seekToDefaultPosition(index)
                                    play()
                                }
                            }
                            as its more error proof and doesn't work with indexes
                            */

                            //but for this simple example we use
                            mediaController?.seekToDefaultPosition(index)
                        },
                        leadingContent = {
                            //coil.compose.AsyncImage
                            AsyncImage(
                                modifier = Modifier
                                    .size(40.dp)
                                    .background(MaterialTheme.colorScheme.surfaceVariant),
                                model = mediaItem.mediaMetadata.artworkData ?: mediaItem.mediaMetadata.artworkUri,
                                contentDescription = "List Item Artwork"
                            )
                        },
                        headlineContent = {
                            Text(mediaItem.mediaMetadata.title?.toString() ?: "Unknown Title")
                        },
                        supportingContent = mediaItem.mediaMetadata.title?.let { { Text(it.toString()) } }
                    )
                }

            }
        },
        bottomBar = {
            mediaController?.run {
                MiniPlayer({ this })
            }
        }
    )


}

The MiniPlayer(PlayBar)

//passing the player as a lambda prevents it from recomposing the MiniPlayer every time something inside the player object itself changes and since we don't observe the player directly this is the right way to do it
@OptIn(UnstableApi::class)
@Composable
fun MiniPlayer(player: () -> Player) {

    val player = player()

    //common default compose media3 methods and some more
    val playPauseButtonState = rememberPlayPauseButtonState(player)
    val previousButtonState = rememberPreviousButtonState(player)
    val nextButtonState = rememberNextButtonState(player)
    // The default seek buttons (if you want them)
    // val defaultSeekBackButtonState = androidx.media3.ui.compose.state.rememberSeekBackButtonState(player)
    // val defaultSeekForwardButtonState = androidx.media3.ui.compose.state.rememberSeekForwardButtonState(player)

    //common custom compose media3 methods and some more
    // These listen for isMediaItemDynamic, allowing seek in live DVR streams
    val seekBackButtonState = rememberSeekBackButtonState(player)
    val seekForwardButtonState = rememberSeekForwardButtonState(player)

    // This gives you easy access to metadata
    val mediaMetadataState = rememberMediaMetadata(player)

    //This gives you the current MediaItem object
    //a basic version of a mediaItem without buildUpon() and all the other functions just the basics
    //val currentMediaItemState = rememberCurrentMediaItemState(player)
    //if you prefer to get access to the entire mediaItem and all its functions you should use this method instead as it returns a real State<MediaItem?>
    //val currentMediaItem by rememberCurrentMediaItem(player)

    MiniPlayer(
        isPlaying = !playPauseButtonState.showPlay,
        artwork = mediaMetadataState.artworkData ?: mediaMetadataState.artworkUri,
        title = mediaMetadataState.title,
        artist = mediaMetadataState.artist,
        album = mediaMetadataState.albumTitle,
        onRewind = if (seekBackButtonState.isEnabled) seekBackButtonState::onClick else null,
        onPrevious = if (previousButtonState.isEnabled) previousButtonState::onClick else null,
        onTogglePlayback = if (playPauseButtonState.isEnabled) playPauseButtonState::onClick else null,
        onNext = if (nextButtonState.isEnabled) nextButtonState::onClick else null,
        onForward = if (seekForwardButtonState.isEnabled) seekForwardButtonState::onClick else null,
    )

}

A private version of the MiniPlayer rendering the ui

private fun MiniPlayer(
    //Basic
    isPlaying: Boolean,

    //Basic Metadata
    artwork: Any?,
    title: CharSequence?,
    artist: CharSequence?,
    album: CharSequence?,

    //Basic Buttons
    onRewind: (() -> Unit)?,
    onPrevious: (() -> Unit)?,
    onTogglePlayback: (() -> Unit)?,
    onNext: (() -> Unit)?,
    onForward: (() -> Unit)?,
) = Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
    Column {

        Row(
            modifier = Modifier
                .padding(8.dp)
                .fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
            content = {
                //coil.compose.AsyncImage
                AsyncImage(model = artwork, contentDescription = "Player Artwork", modifier = Modifier
                    .size(40.dp)
                    .background(
                        MaterialTheme.colorScheme.surfaceVariant
                    ))
                Column(modifier = Modifier.weight(1f)) {
                    title?.let {
                        Text(it.toString(), color = MaterialTheme.colorScheme.onSurface)
                    }
                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        artist?.let {
                            Text(it.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant)
                        }
                        album?.let {
                            Text(it.toString(), color = MaterialTheme.colorScheme.onSurfaceVariant)
                        }
                    }

                }
            }
        )

        HorizontalDivider()

        Row(
            modifier = Modifier
                .padding(8.dp)
                .fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceEvenly,
            content = {

                onRewind?.let {
                    IconButton(onClick = it, content = { Icon(imageVector = Icons.Default.FastRewind, contentDescription = "Fast Rewind") })
                }

                onPrevious?.let {
                    IconButton(onClick = it, content = { Icon(imageVector = Icons.Default.SkipPrevious, contentDescription = "Previous") })
                }

                onTogglePlayback?.let {
                    FilledIconButton(
                        shape = if (isPlaying) IconButtonDefaults.smallSquareShape else IconButtonDefaults.smallRoundShape,
                        onClick = it,
                        content = {
                            Icon(
                                imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
                                contentDescription = if (isPlaying) "Pause" else "Play"
                            )
                        }
                    )
                }

                onNext?.let {
                    IconButton(onClick = it, content = { Icon(imageVector = Icons.Default.SkipNext, contentDescription = "Next") })
                }

                onForward?.let {
                    IconButton(onClick = it, content = { Icon(imageVector = Icons.Default.FastForward, contentDescription = "Fast Forward") })
                }

            }
        )

    }
}

###The Problem the media3 library recently introduced functions for using the library with compose the remember*State requires a non null player to be passed to them and how are we supposed to do that a very important function is missing in the library and thats a rememberMediaController function, otherwise we would have to make alot of boilerplate code in our activity and then pass down the player from the activity, i have browsed a lot of open source apps on github to try to find the perfect way to connect a service to the compose ui and i havn't found a single one im satisfied about they all feel like a bandage for a big problem, and it feels very anti-compose, so i made a solution myself meet the rememberMediaController

The one and only solution

rememberMediaController is the one-line function we all have been missing and its made with compose in mind im sure it's not perfect either and maybe there could be some optimisations to make i did not catch

Alternatives considered

A clear and concise description of any alternative solutions you considered, if applicable.

here is a link to my GitHub repo where all the code is freely available to use https://github.com/at-oliverapps/media3-compose-utils/

at-oliverapps avatar Nov 12 '25 00:11 at-oliverapps

Thanks for sharing!

It's great to see your solution has already its own GitHub repository so other app developer can take advantage of your work. Thank you very much for your engagement and your innovative spirit!

this is my first real and serious contribution on GitHub so forgive me if its not perfect

Woow! That's cool! I wish I had such a great start! Well done!

It's unlikely and license-wise difficult for us to rely on any code in your repo I'm afraid. If you want to do a high-quality pull request to suggest your solution being part of Media3 that's an option.

I'm not super closely familiar with of our concrete plans regarding the Compose road map though. I'm re-assigning to our Compose experts. I'm sure they can appreciate your efforts more than I am able to.

marcbaechinger avatar Nov 12 '25 13:11 marcbaechinger

Thank you for your suggestion, we should definitely have a one-liner, although as you said, it would just remove a tiny bit of boilerplate, nothing more. Our new remember*State functions are hiding the fact that we are launch-ing a suspend function in there to start observe-ing Player.Events to update the class.

The question is then, what would such a rememberMediaController be trying to hide other than an initialization of the object?

We have a request to make buildAsync suspendable - https://github.com/androidx/media/issues/1533, for example. I am not sure if it's a blocker for this enhancement.

The naive way I imagine this would work is the following pseudo-code which I haven't ran

@Composable
fun rememberMediaController(): MediaController? {
    val context = LocalContext.current
    val sessionToken = remember { SessionToken(context, ???) }
    var mediaController by remember { mutableStateOf<MediaController?>(null) }

    // This is the problematic conversion
    DisposableEffect(sessionToken) {
        val future: ListenableFuture<MediaController> = 
                 MediaController.Builder(context, sessionToken).buildAsync()

        future.addListener(
            {
              try {
                  mediaController = future.get()
              } catch (e: ExecutionException) {
                  // Handle connection error
              }
            },
            ?? some executor
        )

        onDispose {
            MediaController.releaseFuture(future)
        }
    }

    return mediaController
}

We had to use DisposableEffect, but if we had a hypothetical suspend fun buildAsync(): MediaController, we could rewrite it without any listeners and futures.

// We use LaunchedEffect to get a coroutine scope tied to the composable's lifecycle
LaunchedEffect(sessionToken) {
   try {
      controller = MediaController.Builder(context, sessionToken).build() 

I wonder if we can already use LaunchedEffect if we use https://developer.android.com/develop/background-work/background-tasks/asynchronous/listenablefuture#suspending_in_kotlin and call await() from inside that coroutine. As Marc said, this is a deep design change, so if you have a working PR, we would consider it, but until then, I will leave the issue for now.

oceanjules avatar Nov 13 '25 01:11 oceanjules