任务三 实现视频控制与进度显示
任务二已经完成 MV 播放,现在为视频页面增加播放/暂停、时间显示和进度拖动,让用户能查看并调整视频播放位置。
一、任务目标
完成后,用户将能够:
- 点击视频画面或播放图标切换播放和暂停。
- 看到当前播放时间与视频总时长。
- 进度条范围与视频实际时长一致,播放结束后进度和当前时间回到起点。
- 拖动 SeekBar 跳转到指定位置。
- 在拖动期间保持用户选择,不被自动进度刷新覆盖。
- 离开视频页面时停止进度刷新。
二、完善视频页面布局
1. 添加进度条和时间文本
打开 activity_video.xml,在视频画面底部增加 SeekBar 和时间文本。进度条贴近页面底部,时间文本放在进度条上方,避免与歌曲信息重叠。
<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 宽度,并让它们的右边界与进度条对齐。歌曲信息排在时间文本上方,过长的文字只显示一行,避免挤压底部控件。
<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 末尾加入播放图标,放在视频画面中央,作为自定义控制入口。
<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 判断当前状态,再决定暂停还是继续播放。
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():播放时隐藏图标,暂停时显示图标。
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修饰。
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() 启动每秒更新播放时间和进度的任务。
import androidx.lifecycle.lifecycleScopeimport com.sziit.musicplayer.utils.formatTimeRangeimport kotlinx.coroutines.Jobimport kotlinx.coroutines.delayimport kotlinx.coroutines.isActiveimport 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() 中根据播放状态开始或停止进度任务。
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() 中释放播放器。现在进度刷新任务也已创建,需要在这两个方法中先停止刷新,再处理播放器。
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(准备完成)后,才能读取视频总时长并设置进度条范围;播放结束时,让进度和当前时间回到起点。
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()。
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 秒。继续检查:
- 拖动过程中,进度条是否保持在手指选定的位置,没有被自动刷新拉回。
- 视频播放结束后,进度和当前时间是否回到起点,总时长是否仍然显示。
- 返回列表后,视频声音是否停止;再次进入视频页面后,进度是否正常更新。
任务小结
视频页面已经支持播放控制、时间显示和进度拖动。离开页面时会暂停视频并停止进度刷新,销毁页面时会释放播放器。