You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Media3+Jetpack Compose音乐应用:通知切歌UI不更新问题求助

问题:Media3 + Jetpack Compose 音乐应用中,系统通知切换歌曲时UI不更新

使用Media3和Jetpack Compose开发音乐应用,通过自定义Composable组件(PlayerControls)切换歌曲时,UI能正常更新当前歌曲的歌手、歌名、封面等信息;但通过系统通知切换歌曲时,歌曲切换成功,但UI仍显示上一首歌曲的状态,未同步更新。

排查发现:非通知方式切歌时selectedSongIndex会变化,而通知切歌时索引保持不变,核心原因是UI未监听通知触发的播放事件。

以下是相关代码实现:

Song 数据类

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
)

PlayerStates 枚举

enum class PlayerStates {
    STATE_IDLE,
    STATE_READY,
    STATE_BUFFERING,
    STATE_ERROR,
    STATE_END,
    STATE_PLAYING,
    STATE_PAUSE,
    STATE_CHANGE_SONG
}

PlayerEvents 接口

interface PlayerEvents {
    fun onPlayPauseClick()
    fun onPreviousClick()
    fun onNextClick()
    fun onSongClick(song: Song)
    fun onSeekBarPositionChanged(position: Long)
}

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)")
    }

}

扩展函数

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)
}

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) }
    }
}

解决方案

问题核心是系统通知切歌时,SongViewModel中的selectedSongIndex未同步更新——通知触发的切歌直接操作ExoPlayer的previous()/next()方法,但现有代码只监听了自动切歌的事件,未覆盖手动切歌(含通知触发)的场景。

1. 扩展SongServiceHandler的媒体项监听逻辑

添加状态流暴露当前播放的媒体项索引,同时修改onMediaItemTransition覆盖所有切歌场景:

// 在SongServiceHandler中新增状态流
val currentMediaItemIndex = MutableStateFlow(-1)

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
    super.onMediaItemTransition(mediaItem, reason)
    // 同步当前播放的媒体项索引
    currentMediaItemIndex.tryEmit(player.currentMediaItemIndex)
    // 所有切歌场景都发送状态更新
    mediaState.tryEmit(PlayerStates.STATE_CHANGE_SONG)
    mediaState.tryEmit(if (player.playWhenReady) PlayerStates.STATE_PLAYING else PlayerStates.STATE_PAUSE)
}

// 初始化和手动切歌后同步索引
fun initPlayer(songList: MutableList<MediaItem>) {
    player.setMediaItems(songList)
    player.prepare()
    currentMediaItemIndex.tryEmit(player.currentMediaItemIndex)
}

fun setUpSong(index: Int, isSongPlay: Boolean) {
    if (player.playbackState == Player.STATE_IDLE) player.prepare()
    player.seekTo(index, 0)
    if (isSongPlay) player.playWhenReady = true
    currentMediaItemIndex.tryEmit(index)
    Log.d("service", "fun setUpSong()")
}

2. 在SongViewModel中监听索引变化

添加对currentMediaItemIndex的监听,索引变化时同步更新UI状态:

init {
    viewModelScope.launch {
        loadData()
        observePlayerState()
        // 新增:监听媒体项索引变化
        songServiceHandler.currentMediaItemIndex.collect { newIndex ->
            if (newIndex != -1 && newIndex != selectedSongIndex) {
                selectedSongIndex = newIndex
                updateSelectedSongUI()
            }
        }
    }
}

// 新增方法:更新选中歌曲的UI状态
private fun updateSelectedSongUI() {
    _songs.resetSongs()
    val currentState = songServiceHandler.mediaState.value
    _songs[selectedSongIndex].apply {
        isSelected = true
        state = if (currentState == PlayerStates.STATE_PLAYING) PlayerStates.STATE_PLAYING else PlayerStates.STATE_PAUSE
    }
    selectedSong = _songs[selectedSongIndex]
    updatePlaybackState(currentState)
}

3. 优化updateState方法的自动切歌逻辑

移除原逻辑中通过STATE_CHANGE_SONG触发onNextClick()的代码,避免重复操作:

private fun updateState(state: PlayerStates) {
    if (selectedSongIndex != -1) {
        isSongPlay = state == PlayerStates.STATE_PLAYING
        _songs[selectedSongIndex].state = state
        _songs[selectedSongIndex].isSelected = true
        selectedSong = _songs[selectedSongIndex]

        updatePlaybackState(state)
        // 移除自动切歌触发逻辑
        if (state == PlayerStates.STATE_END) {
            onSongSelected(0)
        }
    }
}

4. 确保通知切歌直接调用ExoPlayer方法

在媒体服务的MediaSession.Callback中,处理通知事件时直接调用player的对应方法:

override fun onSkipToPrevious() {
    player.previous()
}

override fun onSkipToNext() {
    player.next()
}

修改后,无论通过UI组件还是系统通知切歌,selectedSongIndex都会同步更新,UI也会自动刷新当前歌曲信息。


内容的提问来源于stack exchange,提问作者LucasMilhan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 09:35:54