任务五 实现播放控制与进度拖动

任务四已经让播放栏显示当前歌曲和播放状态。本任务继续补全播放器操作:暂停或继续播放、切换歌曲,并查看和拖动播放进度。

一、任务目标

完成后,用户可以:

  • 暂停或继续当前歌曲。
  • 切换到上一首或下一首;歌曲自然结束后自动播放下一首。
  • 查看实时播放时间和总时长,并拖动进度条跳转。

二、实现暂停和切歌

1. 在服务中添加播放控制

打开 MusicService.kt,增加暂停、继续和切歌方法。切歌时将索引限制在列表范围内,避免越界。

app/src/main/java/com/sziit/musicplayer/service/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() 初始化监听。暂停按钮根据当前状态调用对应方法,上一首和下一首直接交给服务处理。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
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:

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import 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()。新任务开始前先取消旧任务,保证同时只有一个任务读取播放位置。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import 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() 的准备完成回调中,播放器启动并发布状态后启动进度更新:

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
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() 扩展为:

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
class MusicService : Service() {
// ... 省略其他成员和方法。
fun resume() {
if (!player.isPlaying && currentIndex >= 0) {
player.start()
_playbackState.update { it.copy(isPlaying = true) }
// 恢复播放后重新启动进度刷新。
startProgressUpdates()
}
}
}

3. 在停止播放时清理进度任务

暂停、歌曲播放完成和服务销毁时都要取消进度任务。先在 pause() 中补充取消操作:

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
class MusicService : Service() {
// ... 省略其他成员和方法。
fun pause() {
if (player.isPlaying) {
player.pause()
_playbackState.update { it.copy(isPlaying = false) }
// 暂停后停止定时读取播放位置。
progressJob?.cancel()
}
}
}

在 playImpl() 已有的播放完成监听中,补充取消进度任务和自动切歌逻辑。歌曲结束后清零进度;若后面还有歌曲,则自动播放下一首。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
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() 中取消任务和作用域:

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
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,编写毫秒到“分:秒”的转换函数。

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()。用户拖动期间先不采用服务的自动进度,避免进度条被定时值拉回。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
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(),由服务操作播放器并同步新的播放位置。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
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()。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
import android.widget.SeekBar
import 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 控制暂停、继续和切歌。服务定时发布进度,播放栏显示歌曲信息和播放时间,并在用户松开进度条后请求跳转。