任务五 实现播放控制与进度拖动
任务四已经让播放栏显示当前歌曲和播放状态。本任务继续补全播放器操作:暂停或继续播放、切换歌曲,并查看和拖动播放进度。
一、任务目标
完成后,用户可以:
- 暂停或继续当前歌曲。
- 切换到上一首或下一首;歌曲自然结束后自动播放下一首。
- 查看实时播放时间和总时长,并拖动进度条跳转。
二、实现暂停和切歌
1. 在服务中添加播放控制
打开 MusicService.kt,增加暂停、继续和切歌方法。切歌时将索引限制在列表范围内,避免越界。
class MusicService : Service() { // ... 省略已有字段和其他方法。
fun pause() { if (player.isPlaying) { // 暂停播放器,并将新状态发布给页面。 player.pause() _playbackState.update { it.copy(isPlaying = false) } } }
fun resume() { if (!player.isPlaying && currentIndex >= 0) { // 继续当前歌曲,并同步播放状态。 player.start() _playbackState.update { it.copy(isPlaying = true) } } }
fun playNext() { if (musicList.isNotEmpty()) { // 已是最后一首时,重新播放最后一首。 playImpl((currentIndex + 1).coerceAtMost(musicList.lastIndex)) } }
fun playPre() { if (musicList.isNotEmpty()) { // 已是第一首时,重新播放第一首。 playImpl((currentIndex - 1).coerceAtLeast(0)) } }}pause() 和 resume() 操作播放器后更新 PlaybackState,播放栏随之切换图标。在第一首点击上一首,会重新播放第一首;在最后一首点击下一首,会重新播放最后一首。
2. 将播放栏按钮连接到服务
在 PlayerFragment.kt 中添加点击监听,并在 onViewCreated() 初始化监听。暂停按钮根据当前状态调用对应方法,上一首和下一首直接交给服务处理。
class PlayerFragment : Fragment() { // ... 省略已有字段和其他方法。
private fun initListeners() { binding.btnPlay.setOnClickListener { val service = musicService ?: return@setOnClickListener // 根据服务中的播放状态选择暂停或继续。 if (service.playbackState.value.isPlaying) { service.pause() } else { service.resume() } } binding.btnPre.setOnClickListener { musicService?.playPre() } binding.btnNext.setOnClickListener { musicService?.playNext() } }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initListeners() // ... 省略后续的服务绑定代码。 }}3. 验证播放控制
运行应用并播放歌曲,点击播放栏的暂停/播放、上一首和下一首按钮,确认歌曲与图标相应变化;在第一首或最后一首继续切歌,确认列表不会越界。
三、定时发布播放进度
1. 为 MusicService 创建协程作用域
切换页面后,歌曲可能还在播放,进度也要继续更新。因此,这个协程要由 MusicService 管理,不能放在离开页面后就会停止的 viewLifecycleOwner.lifecycleScope 中。
在 MusicService.kt 中添加 serviceScope 和 progressJob:
import kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.Jobimport kotlinx.coroutines.SupervisorJob
class MusicService : Service() { // ... 省略已有字段。 // 用于启动定时更新播放进度的协程。 private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // 记录正在运行的进度更新任务;此时任务还未启动。 private var progressJob: Job? = null
// ... 省略服务中的其他方法。}代码中的 CoroutineScope(...) 创建了 serviceScope。接下来,我们会用它启动每秒更新播放进度的协程。创建作用域时,还需要指定如何管理这些协程,以及让它们在哪个线程运行。这些设置合起来称为 CoroutineContext,作为参数传给 CoroutineScope(...)。这里用 + 组合了两项设置:
SupervisorJob():可以把它看作serviceScope的“总开关”。通过serviceScope启动的协程各自运行:一个协程出错,不会让其他协程一起停止;调用serviceScope.cancel()关闭总开关时,仍在运行的协程都会停止。Dispatchers.Main:指定主线程作为默认运行线程。以后通过serviceScope启动的协程,默认会在 Android 主线程运行。
serviceScope 管理从中启动的协程;progressJob 只记录进度更新这一项任务。暂停播放时取消 progressJob,不必停止整个 serviceScope。
2. 每秒更新一次进度
在 MusicService 中添加 startProgressUpdates()。新任务开始前先取消旧任务,保证同时只有一个任务读取播放位置。
import kotlinx.coroutines.delayimport kotlinx.coroutines.isActiveimport kotlinx.coroutines.launch
class MusicService : Service() { // ... 省略已有字段和其他方法。
private fun startProgressUpdates() { // 先取消旧任务,避免重复发布进度。 progressJob?.cancel() progressJob = serviceScope.launch { while (isActive) { if (player.isPlaying) { // 读取播放器位置并通知页面刷新。 _playbackState.update { it.copy(currentPosition = player.currentPosition.coerceAtLeast(0)) } } delay(1000) } } }}在 playImpl() 的准备完成回调中,播放器启动并发布状态后启动进度更新:
class MusicService : Service() { // ... 省略其他成员和方法。
private fun playImpl(index: Int) { // ... 省略歌曲校验及播放器数据源设置。 player.setOnPreparedListener { mediaPlayer -> currentIndex = index mediaPlayer.start() _playbackState.value = PlaybackState( isPlaying = true, currentSong = song, duration = mediaPlayer.duration.coerceAtLeast(0) ) startProgressUpdates() } }
// ... 省略其他方法。}继续播放时也要启动进度更新。将 resume() 扩展为:
class MusicService : Service() { // ... 省略其他成员和方法。
fun resume() { if (!player.isPlaying && currentIndex >= 0) { player.start() _playbackState.update { it.copy(isPlaying = true) } // 恢复播放后重新启动进度刷新。 startProgressUpdates() } }}3. 在停止播放时清理进度任务
暂停、歌曲播放完成和服务销毁时都要取消进度任务。先在 pause() 中补充取消操作:
class MusicService : Service() { // ... 省略其他成员和方法。
fun pause() { if (player.isPlaying) { player.pause() _playbackState.update { it.copy(isPlaying = false) } // 暂停后停止定时读取播放位置。 progressJob?.cancel() } }}在 playImpl() 已有的播放完成监听中,补充取消进度任务和自动切歌逻辑。歌曲结束后清零进度;若后面还有歌曲,则自动播放下一首。
class MusicService : Service() { // ... 省略其他成员和方法。
private fun playImpl(index: Int) { // ... 保留已有的准备完成监听。 player.setOnCompletionListener { progressJob?.cancel() _playbackState.update { it.copy(isPlaying = false) } // 歌曲结束后将播放状态和进度归零。 _playbackState.update { it.copy(isPlaying = false, currentPosition = 0) } if (currentIndex < musicList.lastIndex) { // 列表中还有歌曲时自动播放下一首。 playNext() } } }}播放到列表最后一首时不再调用 playNext(),因此歌曲不会循环重播。服务销毁时,在 onDestroy() 中取消任务和作用域:
import kotlinx.coroutines.cancel
class MusicService : Service() { // ... 省略其他成员和方法。
override fun onDestroy() { // 服务销毁时取消进度任务和协程作用域。 progressJob?.cancel() serviceScope.cancel() player.release() super.onDestroy() }}4. 验证歌曲播放完成行为
播放一首歌曲并等待结束,确认中间歌曲会自动切换到下一首;播放列表最后一首时,确认歌曲结束后停止播放且不会重新开始。
四、格式化时间并显示进度
1. 创建时间格式化函数
在 app/src/main/java/com/sziit/musicplayer 下新建 utils 包,再在该包中新建 DurationUtils.kt,编写毫秒到“分:秒”的转换函数。
package com.sziit.musicplayer.utils
fun formatDuration(milliseconds: Int): String { // 将毫秒转换为总秒数,再拆分为分钟和秒。 val totalSeconds = (milliseconds / 1000).coerceAtLeast(0) val minutes = totalSeconds / 60 val seconds = totalSeconds % 60 return "%02d:%02d".format(minutes, seconds)}例如,125000 毫秒会显示为 02:05。
2. 在页面中显示时间和进度
在 PlayerFragment 中增加拖动标记,并在收集状态时调用 updateProgress()。用户拖动期间先不采用服务的自动进度,避免进度条被定时值拉回。
import com.sziit.musicplayer.utils.formatDuration
class PlayerFragment : Fragment() { // ... 省略已有字段。 private var isUserDragging = false
private fun updateProgress(current: Int, total: Int) { val safeTotal = total.coerceAtLeast(0) val safeCurrent = current.coerceIn(0, safeTotal) // 更新时间文本、总时长和进度条。 binding.tvBgTime.text = formatDuration(safeCurrent) binding.tvEndTime.text = formatDuration(safeTotal) binding.progressBar.max = safeTotal binding.progressBar.progress = safeCurrent }
private fun observePlaybackState() { viewLifecycleOwner.lifecycleScope.launch { musicService?.playbackState?.collectLatest { state -> updateSongInfo(state.currentSong) updatePlayState(state.isPlaying) if (!isUserDragging) { updateProgress(state.currentPosition, state.duration) } } } }}safeTotal 和 safeCurrent 将时间限制在有效范围内,避免 SeekBar 收到负数或超过总时长的进度。
3. 验证播放进度显示
运行应用并播放歌曲,确认当前时间和进度条持续更新;暂停后,确认时间与进度停止变化,继续播放后再恢复更新。
五、拖动进度条跳转
1. 在服务中执行跳转
在 MusicService.kt 中添加 seekTo(),由服务操作播放器并同步新的播放位置。
class MusicService : Service() { // ... 省略已有字段和其他方法。
fun seekTo(progress: Int) { try { // 跳转到指定位置,并立即同步页面进度。 player.seekTo(progress) _playbackState.update { it.copy(currentPosition = progress) } } catch (e: IllegalStateException) { e.printStackTrace() } }}2. 松开进度条时请求跳转
在 PlayerFragment.kt 中导入 SeekBar,并将监听代码加入已有的 initListeners()。拖动过程中只预览时间,松手后再调用前一步添加的 seekTo()。
import android.widget.SeekBarimport com.sziit.musicplayer.utils.formatDuration
class PlayerFragment : Fragment() { // ... 省略已有字段和其他方法。
private fun initListeners() { // ... 省略已有的播放、上一首和下一首监听。 binding.progressBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged( seekBar: SeekBar?, progress: Int, fromUser: Boolean ) { if (fromUser) { // 拖动时先预览时间,不立即请求播放器跳转。 binding.tvBgTime.text = formatDuration(progress) } }
override fun onStartTrackingTouch(seekBar: SeekBar?) { // 暂停自动进度刷新,避免覆盖手动选择的位置。 isUserDragging = true }
override fun onStopTrackingTouch(seekBar: SeekBar?) { isUserDragging = false // 松开后才让服务跳转到目标位置。 musicService?.seekTo(seekBar?.progress ?: return) } }) }}3. 验证进度拖动
播放歌曲并拖动进度条,确认时间文本跟随手指变化;松开后,歌曲应从选定位置继续播放。
任务小结
播放栏按钮通过 MusicService 控制暂停、继续和切歌。服务定时发布进度,播放栏显示歌曲信息和播放时间,并在用户松开进度条后请求跳转。