I'm creating a music application using Media3 and Jetpack Compose. When I change the song through my composable (PlayerControls), my UI updates correctly, showing artist, song name, image, and other details of the currently playing song.
However, when I change the song through the notification, it does switch correctly, but my UI still appears as if the previous song is playing. Therefore, it's not updating.
The index changes when it's not changed through the notification, but when it's changed through the notification, the index remains the same. That's why my UI isn't updating; it's not listening to the events from my notification.
My Song data class:
data class Song(
val mediaId: String = "",
val artist: String = "",
val songName: String = "",
val songUrl: String = "",
val imageUrl: String = "",
var isSelected: Boolean = false,
var state: PlayerStates = PlayerStates.STATE_IDLE
)
My PlayerStates:
enum class PlayerStates {
STATE_IDLE,
STATE_READY,
STATE_BUFFERING,
STATE_ERROR,
STATE_END,
STATE_PLAYING,
STATE_PAUSE,
STATE_CHANGE_SONG
}
My PlayerEvents:
interface PlayerEvents {
fun onPlayPauseClick()
fun onPreviousClick()
fun onNextClick()
fun onSongClick(song: Song)
fun onSeekBarPositionChanged(position: Long)
}
My SongServiceHandler:
class SongServiceHandler @Inject constructor(
private val player: ExoPlayer
) : Player.Listener {
val mediaState = MutableStateFlow(PlayerStates.STATE_IDLE)
val currentPlaybackPosition: Long
get() = if (player.currentPosition > 0) player.currentPosition else 0L
val currentSongDuration: Long
get() = if (player.duration > 0) player.duration else 0L
private var job: Job? = null
init {
player.addListener(this)
job = Job()
}
fun initPlayer(songList: MutableList<MediaItem>) {
player.setMediaItems(songList)
player.prepare()
}
fun setUpSong(index: Int, isSongPlay: Boolean) {
if (player.playbackState == Player.STATE_IDLE) player.prepare()
player.seekTo(index, 0)
if (isSongPlay) player.playWhenReady = true
Log.d("service", "fun setUpSong()")
}
fun playPause() {
if (player.playbackState == Player.STATE_IDLE) player.prepare()
player.playWhenReady = !player.playWhenReady
}
fun releasePlayer() {
player.release()
}
fun seekToPosition(position: Long) {
player.seekTo(position)
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
mediaState.tryEmit(PlayerStates.STATE_ERROR)
Log.d("service", "override fun onPlayerError(error = ${mediaState.value})")
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (player.playbackState == Player.STATE_READY) {
if (playWhenReady) {
mediaState.tryEmit(PlayerStates.STATE_PLAYING)
} else {
mediaState.tryEmit(PlayerStates.STATE_PAUSE)
}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
if (reason == MEDIA_ITEM_TRANSITION_REASON_AUTO) {
mediaState.tryEmit(PlayerStates.STATE_CHANGE_SONG)
mediaState.tryEmit(PlayerStates.STATE_PLAYING)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_IDLE -> {
mediaState.tryEmit(PlayerStates.STATE_IDLE)
}
Player.STATE_BUFFERING -> {
mediaState.tryEmit(PlayerStates.STATE_BUFFERING)
}
Player.STATE_READY -> {
mediaState.tryEmit(PlayerStates.STATE_READY)
if (player.playWhenReady) {
mediaState.tryEmit(PlayerStates.STATE_PLAYING)
} else {
mediaState.tryEmit(PlayerStates.STATE_PAUSE)
}
}
Player.STATE_ENDED -> {
mediaState.tryEmit(PlayerStates.STATE_END)
}
}
Log.d("service", "override fun onPlaybackStateChanged(playbackState = $playbackState)")
}
}
My Exntensions:
fun MutableList<Song>.resetSongs() {
this.forEach { song ->
song.isSelected = false
song.state = PlayerStates.STATE_IDLE
}
}
fun CoroutineScope.collectPlayerState(
songServiceHandler: SongServiceHandler,
updateState: (PlayerStates) -> Unit
) {
this.launch {
songServiceHandler.mediaState.collect {
updateState(it)
}
}
}
fun CoroutineScope.launchPlaybackStateJob(
playbackStateFlow: MutableStateFlow<PlaybackState>,
state: PlayerStates,
songServiceHandler: SongServiceHandler
) = launch {
do {
playbackStateFlow.emit(
PlaybackState(
currentPlaybackPosition = songServiceHandler.currentPlaybackPosition,
currentSongDuration = songServiceHandler.currentSongDuration
)
)
delay(1000)
} while (state == PlayerStates.STATE_PLAYING && isActive)
}
My SongViewModel:
@HiltViewModel
class SongViewModel @Inject constructor(
private val songServiceHandler: SongServiceHandler,
private val repository: SongRepository
) : ViewModel(), PlayerEvents {
private val _songs = mutableStateListOf<Song>()
val songs: List<Song> get() = _songs
private var isSongPlay: Boolean = false
var selectedSong: Song? by mutableStateOf(null)
private set
private var selectedSongIndex: Int by mutableStateOf(-1)
private val _playbackState = MutableStateFlow(PlaybackState(0L, 0L))
val playbackState: StateFlow<PlaybackState> get() = _playbackState
var isServiceRunning = false
private var playbackStateJob: Job? = null
private var isAuto: Boolean = false
init {
viewModelScope.launch {
loadData()
observePlayerState()
}
}
private fun loadData() = viewModelScope.launch {
_songs.addAll(repository.getAllSongs())
songServiceHandler.initPlayer(
_songs.map { song ->
MediaItem.Builder()
.setMediaId(song.mediaId)
.setUri(song.songUrl.toUri())
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(song.songName)
.setArtist(song.artist)
.setArtworkUri(song.imageUrl.toUri())
.build()
).build()
}.toMutableList()
)
}
private fun onSongSelected(index: Int) {
if (selectedSongIndex == -1) isSongPlay = true
if (selectedSongIndex == -1 || selectedSongIndex != index) {
_songs.resetSongs()
selectedSongIndex = index
setUpSong()
}
}
private fun setUpSong() {
if (!isAuto) {
songServiceHandler.setUpSong(
selectedSongIndex,
isSongPlay
)
isAuto = false
}
}
private fun updateState(state: PlayerStates) {
if (selectedSongIndex != -1) {
isSongPlay = state == PlayerStates.STATE_PLAYING
_songs[selectedSongIndex].state = state
_songs[selectedSongIndex].isSelected = true
selectedSong = null
selectedSong = _songs[selectedSongIndex]
updatePlaybackState(state)
if (state == PlayerStates.STATE_CHANGE_SONG) {
isAuto = true
onNextClick()
}
if (state == PlayerStates.STATE_END) {
onSongSelected(0)
}
}
}
private fun updatePlaybackState(state: PlayerStates) {
playbackStateJob?.cancel()
playbackStateJob = viewModelScope
.launchPlaybackStateJob(
_playbackState,
state,
songServiceHandler
)
}
private fun observePlayerState() {
viewModelScope.collectPlayerState(songServiceHandler, ::updateState)
}
override fun onCleared() {
super.onCleared()
songServiceHandler.releasePlayer()
}
override fun onPlayPauseClick() {
songServiceHandler.playPause()
}
override fun onPreviousClick() {
if (selectedSongIndex > 0) {
onSongSelected(selectedSongIndex - 1)
}
}
override fun onNextClick() {
if (selectedSongIndex < _songs.size - 1) {
onSongSelected(selectedSongIndex + 1)
}
}
override fun onSongClick(song: Song) {
onSongSelected(_songs.indexOf(song))
}
override fun onSeekBarPositionChanged(position: Long) {
viewModelScope.launch { songServiceHandler.seekToPosition(position) }
}
}
I would like my UI to update when the music is changed via the notification (next/previous the index), just as it does when it's changed without using the notification.
You collect the SongServiceHandler.mediaState
flow in the ViewModel. This might introduce some undesired behavior, possibly the one you are observing.
Generally, ViewModels should only transform the flows it retrieves from the repositories using mapLatest()
, flatMapLatest()
and so on. Just the Compose code will eventually call collectAsStateWithLifecycle()
on the transformed flows, triggering their execution only when needed. This way you won't have any intermediate state objects that can get stale when not updated properly caused by inconsistent flow subscriptions.
You can refactor your code so that you have a player service that is the single source of truth for things like the current playlist, settings (shuffle/repeat), etc.; any changes it receives from the ViewModel or a Notification are emitted as a flow. Now, the ViewModel just needs to transform these flows to something your UI can consume: That will be essentially what updateState()
currently does, except you don't want any variables updated as a side effect, instead, everything that's needed will have to be contained in the return value. The ViewModel can then map the flows according to this function. If you need to transform one flow into multiple flows make sure the underlying flow is a SharedFlow
.
This way your ViewModel will not contain any intermediate state. After all, the ViewModel's responsibility is to transform business data so the UI can easily display it. Requests to change this data are passed to the repository, it doesn't need to update any state itself. The only data that it should store (and update) itself is UI related state that cannot/should not be stored using Compose.
This is a bigger refactoring, but it will probably fix your current problem along the way.