-
Notifications
You must be signed in to change notification settings - Fork 665
Description
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/