kotlinserviceandroid-jetpack-composemedia-playerandroid-media3

Don't update my UI when I change the music through the media notification


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.


Solution

  • 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.