任务三 实现视频控制与进度显示

任务二已经完成 MV 播放,现在为视频页面增加播放/暂停、时间显示和进度拖动,让用户能查看并调整视频播放位置。

一、任务目标

完成后,用户将能够:

  • 点击视频画面或播放图标切换播放和暂停。
  • 看到当前播放时间与视频总时长。
  • 进度条范围与视频实际时长一致,播放结束后进度和当前时间回到起点。
  • 拖动 SeekBar 跳转到指定位置。
  • 在拖动期间保持用户选择,不被自动进度刷新覆盖。
  • 离开视频页面时停止进度刷新。

二、完善视频页面布局

1. 添加进度条和时间文本

打开 activity_video.xml,在视频画面底部增加 SeekBar 和时间文本。进度条贴近页面底部,时间文本放在进度条上方,避免与歌曲信息重叠。

app/src/main/res/layout/activity_video.xml
<androidx.constraintlayout.widget.ConstraintLayout ...>
<!-- ... 省略已有控件。 -->
<SeekBar
android:id="@+id/progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/tv_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_marginBottom="2dp"
android:text="00:00/00:00"
android:textColor="@android:color/white"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/progress"
app:layout_constraintEnd_toEndOf="@id/progress" />
</androidx.constraintlayout.widget.ConstraintLayout>

2. 整理歌曲信息的约束

把歌曲名称和歌手名称改为 0dp 宽度,并让它们的右边界与进度条对齐。歌曲信息排在时间文本上方,过长的文字只显示一行,避免挤压底部控件。

app/src/main/res/layout/activity_video.xml
<androidx.constraintlayout.widget.ConstraintLayout ...>
<!-- ... 省略其他控件。 -->
<TextView
android:id="@+id/tv_song"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintEnd_toEndOf="@id/progress"
... />
<TextView
android:id="@+id/tv_singer"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_marginBottom="16dp"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@id/tv_duration"
app:layout_constraintEnd_toEndOf="@id/progress"
... />
</androidx.constraintlayout.widget.ConstraintLayout>

3. 添加播放图标

在 ConstraintLayout 末尾加入播放图标,放在视频画面中央,作为自定义控制入口。

app/src/main/res/layout/activity_video.xml
<androidx.constraintlayout.widget.ConstraintLayout ...>
<!-- ... 省略已有控件。 -->
<ImageView
android:id="@+id/img_play"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="播放或暂停"
android:src="@drawable/ic_start"
app:tint="@android:color/white"
app:layout_constraintBottom_toBottomOf="@id/player_view"
app:layout_constraintEnd_toEndOf="@id/player_view"
app:layout_constraintStart_toStartOf="@id/player_view"
app:layout_constraintTop_toTopOf="@id/player_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

工程内的控制图标使用深灰描边。在黑色的视频页面上,通过 app:tint 把图标显示为白色,不需要修改图标文件。

4. 验证页面布局

运行应用并进入 MV 页面,检查中央是否显示白色播放图标,底部是否依次显示歌曲信息、00:00/00:00 和进度条。

视频控制区布局效果

此时只完成了布局:图标还不会随播放状态隐藏,时间和进度也不会自动更新。接下来再连接播放逻辑。

三、实现播放和暂停

1. 添加点击监听

在 onCreate() 中让视频画面和播放图标共用同一个点击方法。方法通过 isPlaying 判断当前状态,再决定暂停还是继续播放。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
class VideoActivity : AppCompatActivity() {
// ... 省略已有成员。
override fun onCreate(savedInstanceState: Bundle?) {
// ... 省略已有代码。
binding.playerView.setOnClickListener { togglePlayPause() }
binding.imgPlay.setOnClickListener { togglePlayPause() }
}
private fun togglePlayPause() {
val currentPlayer = player ?: return
if (currentPlayer.isPlaying) {
currentPlayer.pause()
} else {
currentPlayer.play()
}
}
}

2. 根据播放状态显示图标

在任务二创建的 Player.Listener 中重写 onIsPlayingChanged():播放时隐藏图标,暂停时显示图标。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
import android.view.View
class VideoActivity : AppCompatActivity() {
// ... 省略已有代码。
private fun prepareVideo() {
// ... 省略地址检查。
player = ExoPlayer.Builder(this).build().also { exoPlayer ->
// ... 省略播放器准备代码。
exoPlayer.addListener(object : Player.Listener {
// ... 省略已有的 onPlaybackStateChanged()。
override fun onIsPlayingChanged(isPlaying: Boolean) {
// 播放时隐藏图标,暂停时显示图标。
binding.imgPlay.visibility = if (isPlaying) View.INVISIBLE else View.VISIBLE
}
})
}
}
}

3. 验证播放和暂停

运行应用并进入 MV 页面。视频播放时,中央播放图标应隐藏;点击视频区域后,视频暂停并显示播放图标;点击播放图标后,视频继续播放。

视频播放和暂停效果

四、同步播放进度

1. 使用时间格式化工具

播放器返回的播放位置和视频总时长都以毫秒为单位,显示时需要转换为 分:秒/分:秒。打开已有的 DurationUtils.kt,在文件末尾添加两个格式化函数:

  • formatTimeRange():将当前时间和总时长组合成页面需要的显示文本。
  • formatMmSs():将毫秒数转换成“分:秒”。它只在工具文件内部使用,因此用 private 修饰。
app/src/main/java/com/sziit/musicplayer/utils/DurationUtils.kt
package com.sziit.musicplayer.utils
// ... 省略已有的 formatDuration()。
fun formatTimeRange(currentMs: Long, totalMs: Long): String {
return "${formatMmSs(currentMs)}/${formatMmSs(totalMs)}"
}
private fun formatMmSs(timeMs: Long): String {
// 播放器使用毫秒,页面显示为“分:秒”。
val totalSeconds = (timeMs / 1000).coerceAtLeast(0)
return "%02d:%02d".format(totalSeconds / 60, totalSeconds % 60)
}

2. 添加进度任务

在 VideoActivity 中添加进度任务引用和拖动状态。stopProgressUpdates() 取消当前任务并清空引用,startProgressUpdates() 启动每秒更新播放时间和进度的任务。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
import androidx.lifecycle.lifecycleScope
import com.sziit.musicplayer.utils.formatTimeRange
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class VideoActivity : AppCompatActivity() {
// 保存进度任务,便于暂停时取消。
private var progressJob: Job? = null
private var isUserDragging = false
// ... 省略已有代码。
private fun stopProgressUpdates() {
progressJob?.cancel()
progressJob = null
}
private fun startProgressUpdates() {
stopProgressUpdates()
progressJob = lifecycleScope.launch {
while (isActive) {
val currentPlayer = player ?: break
// 拖动进度条时,不用播放器位置覆盖用户选择。
if (!isUserDragging) {
binding.progress.progress = currentPlayer.currentPosition.toInt()
}
binding.tvDuration.text = formatTimeRange(
if (isUserDragging) binding.progress.progress.toLong() else currentPlayer.currentPosition,
currentPlayer.duration
)
delay(1000)
}
}
}
}

startProgressUpdates() 每秒读取一次播放位置。它先停止旧任务,避免重复刷新;用户拖动进度条时,暂不覆盖用户选定的位置。

3. 根据播放状态开始或停止刷新

在 onIsPlayingChanged() 中根据播放状态开始或停止进度任务。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
class VideoActivity : AppCompatActivity() {
// ... 省略已有代码。
private fun prepareVideo() {
// ... 省略地址检查。
player = ExoPlayer.Builder(this).build().also { exoPlayer ->
// ... 省略播放器准备代码。
exoPlayer.addListener(object : Player.Listener {
// ... 省略 onPlaybackStateChanged()。
override fun onIsPlayingChanged(isPlaying: Boolean) {
binding.imgPlay.visibility = if (isPlaying) View.INVISIBLE else View.VISIBLE
if (isPlaying) startProgressUpdates() else stopProgressUpdates()
}
})
}
}
}

4. 离开页面时停止进度刷新

任务二已经在 onStop() 中暂停视频,在 onDestroy() 中释放播放器。现在进度刷新任务也已创建,需要在这两个方法中先停止刷新,再处理播放器。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
class VideoActivity : AppCompatActivity() {
// ... 省略已有代码。
override fun onStop() {
super.onStop()
stopProgressUpdates()
player?.pause()
}
override fun onDestroy() {
stopProgressUpdates()
player?.release()
player = null
super.onDestroy()
}
}

这样离开页面后,进度任务不会继续读取播放位置;播放器释放前,进度任务也已经停止。

5. 在准备完成后设置进度条范围

SeekBar 的最大值默认是 100,而视频时长以毫秒计。进入 STATE_READY(准备完成)后,才能读取视频总时长并设置进度条范围;播放结束时,让进度和当前时间回到起点。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
class VideoActivity : AppCompatActivity() {
// ... 省略已有代码。
private fun prepareVideo() {
// ... 省略地址检查。
player = ExoPlayer.Builder(this).build().also { exoPlayer ->
// ... 省略播放器准备代码。
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
exoPlayer.pause()
binding.progress.progress = 0
binding.tvDuration.text = formatTimeRange(0, exoPlayer.duration)
} else if (playbackState == Player.STATE_READY) {
// 准备完成后,按视频总时长设置进度条范围。
val duration = exoPlayer.duration
if (duration > 0) {
binding.progress.max = duration.toInt()
}
binding.tvDuration.text = formatTimeRange(exoPlayer.currentPosition, duration)
}
}
// ... 省略已有的 onIsPlayingChanged()。
})
}
}
}

准备完成后,进度条最大值设为视频总时长。播放结束后,当前时间回到 00:00,总时长仍保留;中央播放图标由 onIsPlayingChanged() 负责显示。

6. 监听 SeekBar 拖动

在 onCreate() 中设置监听器。拖动过程中只更新文本,松手后调用 seekTo()。

app/src/main/java/com/sziit/musicplayer/ui/activity/VideoActivity.kt
import android.widget.SeekBar
class VideoActivity : AppCompatActivity() {
// ... 省略已有成员。
override fun onCreate(savedInstanceState: Bundle?) {
// ... 省略已有代码。
binding.progress.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
// 拖动时先更新文字,不立即跳转视频。
binding.tvDuration.text = formatTimeRange(progress.toLong(), player?.duration ?: 0L)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
isUserDragging = true
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
isUserDragging = false
// 松手后才跳转到选定位置。
player?.seekTo(seekBar?.progress?.toLong() ?: return)
}
})
}
}

7. 验证进度显示与拖动

运行应用并进入 MV 页面。视频加载完成后,底部应显示当前播放时间和视频总时长;播放时,当前时间和进度条随播放位置更新。拖动进度条,时间文本应随之变化,松手后视频应跳转到选定位置。

视频进度显示与拖动效果

图中的 02:27/04:19 表示当前播放位置为 2 分 27 秒,视频总时长为 4 分 19 秒。继续检查:

  1. 拖动过程中,进度条是否保持在手指选定的位置,没有被自动刷新拉回。
  2. 视频播放结束后,进度和当前时间是否回到起点,总时长是否仍然显示。
  3. 返回列表后,视频声音是否停止;再次进入视频页面后,进度是否正常更新。

任务小结

视频页面已经支持播放控制、时间显示和进度拖动。离开页面时会暂停视频并停止进度刷新,销毁页面时会释放播放器。