这篇文章接上文Media3 - ExoPlayer 打造音视频播放器(一),上文主要讲解了 Media3 - ExoPlayer 的基本理论,包括一些常用的属性和方法,这篇文章就主要讲讲实操吧!一般来说,音视频播放的界面都是各式各样的,默认的界面不能满足我们的需求,这就需要我们自定义播放器的界面了,使用 Media3 ExoPlayer 自定义播放界面能够提供更加丰富和个性化的用户体验。
控制器布局
在 Media3 PlayerView 中,属性 app:controller_layout_id 指向了一个自定义的控制器布局 control_layout.xml,这个布局文件定义了播放器控制器的外观和组成部分。
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".VideoPlayActivity">
<androidx.media3.ui.PlayerView
android:id="@+id/playView"
android:layout_width="match_parent"
android:layout_height="366dp"
android:background="@color/black"
app:controller_layout_id="@layout/control_layout" />
</LinearLayout>
只要 controller_layout_id 指向了一个自定义的布局,ExoPlayer 默认的所有控制器效果都会消失,都需要自己去实现。这里在顶部定义了一个标题栏,用于显示标题和返回按钮,底部就是控制视频的一些按钮了,比如播放和暂停,上下集切换按钮,播放进度条等等。
xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="@color/black"
android:paddingTop="6dp"
android:paddingBottom="6dp">
<ImageView
android:id="@+id/go_back"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="4dp"
android:src="@drawable/back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="视频标题"
android:textColor="@color/white"
android:textSize="15sp" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:background="@color/black"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="6dp">
<ImageButton
android:id="@+id/pre_btn"
style="@style/ExoMediaButton.Previous"
android:layout_width="30dp"
android:layout_height="30dp"
android:visibility="gone" />
<ImageButton
android:id="@+id/play_btn"
android:layout_width="30dp"
android:layout_height="30dp"
android:background="@drawable/pause" />
<ImageButton
android:id="@+id/next_btn"
style="@style/ExoMediaButton.Next"
android:layout_width="30dp"
android:layout_height="30dp"
android:visibility="gone" />
<SeekBar
android:id="@+id/seekbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/play_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="00:00/00:00"
android:textColor="@color/white"
android:textSize="15sp" />
</LinearLayout>
</RelativeLayout>
实现控制功能
我们先在此加入三个媒体项
kotlin
val mediaItem1 = MediaItem.fromUri(VIDEO_URL1)
val mediaItem2 = MediaItem.fromUri(VIDEO_URL2)
val mediaItem3 = MediaItem.fromUri(VIDEO_URL3)
exoPlayer = ExoPlayer.Builder(this).build()
playView.player = exoPlayer
exoPlayer.apply {
addMediaItem(mediaItem1)
addMediaItem(mediaItem2)
addMediaItem(mediaItem3)
prepare()
}
先来处理一下上一集,下一集,播放或暂停,直接调用 ExoPlayer 的相关方法即可,这个很简单。
kotlin
preBtn.setOnClickListener {
if (exoPlayer.hasPreviousMediaItem()) {
exoPlayer.seekToPreviousMediaItem()
}
}
nextBtn.setOnClickListener {
if (exoPlayer.hasNextMediaItem()) {
exoPlayer.seekToNextMediaItem()
}
}
playBtn.setOnClickListener {
if (isPlaying) {
exoPlayer.pause()
playBtn.background =
ContextCompat.getDrawable(this@VideoPlayActivity, R.drawable.play)
} else {
exoPlayer.play()
playBtn.background =
ContextCompat.getDrawable(this@VideoPlayActivity, R.drawable.pause)
}
}
对于播放进度的处理,我们可以弄一个计时器,每秒检查一次 currentPosition,这个返回的单位是毫秒,所以需要先转化成秒,以便转化为 00:00 的格式去做展示。同时,监听 seekBar 的拖动事件,根据拖动的位置跳转到媒体文件的对应的位置进行播放。
kotlin
private fun setProgress() {
progressJob?.cancel()
progressJob = lifecycleScope.launch {
while (isActive) {
if (!isDragging) {
val currentPosition = exoPlayer.currentPosition
val currentTime = formatTimestamp(currentPosition / 1000)
val totalDuration = exoPlayer.duration
seekBar.max = totalDuration.toInt()
val totalTime = formatTimestamp(totalDuration / 1000)
playTime.text = "$currentTime/$totalTime"
seekBar.progress = currentPosition.toInt()
}
delay(1000)
}
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) { //如果是用户拖动的,则更新播放位置。
exoPlayer.seekTo(progress.toLong())
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
isDragging = true
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
isDragging = false
}
})
}
kotlin
private fun formatTimestamp(timestampInSeconds: Long): String {
val minutes = timestampInSeconds / 60
val seconds = timestampInSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
seekBar 拖动开始会回调 onStartTrackingTouch,拖动结束会回调 onStopTrackingTouch。这里有个小细节,就是 isDragging,正在拖动的时候建议就不要去变换计时器中的播放位置了,以免引起一些不必要的问题。
然后在 onPlaybackStateChanged 中监听播放进度的变化即可
kotlin
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == Player.STATE_READY) {
//底部控制器处理
preBtn.visibility =
if (exoPlayer.hasPreviousMediaItem()) View.VISIBLE else View.GONE
nextBtn.visibility =
if (exoPlayer.hasNextMediaItem()) View.VISIBLE else View.GONE
setProgress()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
[email protected] = isPlaying
}
})
跟随生命周期
当我们的宿主 Activity 处于后台时,需要暂停播放,处于前台时,继续播放,销毁时,释放资源,ExoPlayer 的状态需要跟随宿主的生命周期。
kotlin
override fun onStart() {
super.onStart()
exoPlayer.play()
}
override fun onStop() {
super.onStop()
exoPlayer.pause()
}
override fun onDestroy() {
super.onDestroy()
exoPlayer.release()
}
整个视频播放的 Activity 如下:
kotlin
class VideoPlayActivity : AppCompatActivity() {
private lateinit var exoPlayer: ExoPlayer
private lateinit var playBtn: ImageButton
private lateinit var playTime: TextView
private lateinit var seekBar: SeekBar
private lateinit var preBtn: ImageButton
private lateinit var nextBtn: ImageButton
private var progressJob: Job? = null
private var isPlaying = false
private var isDragging = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_video_play)
initView()
initListener()
}
private fun initView() {
val playView = findViewById<PlayerView>(R.id.playView)
val goBack = playView.findViewById<ImageView>(R.id.go_back)
preBtn = playView.findViewById(R.id.pre_btn)
nextBtn = playView.findViewById(R.id.next_btn)
playBtn = playView.findViewById(R.id.play_btn)
playTime = playView.findViewById(R.id.play_time)
seekBar = playView.findViewById(R.id.seekbar)
val mediaItem1 = MediaItem.fromUri(VIDEO_URL1)
val mediaItem2 = MediaItem.fromUri(VIDEO_URL2)
val mediaItem3 = MediaItem.fromUri(VIDEO_URL3)
exoPlayer = ExoPlayer.Builder(this).build()
playView.player = exoPlayer
exoPlayer.apply {
addMediaItem(mediaItem1)
addMediaItem(mediaItem2)
addMediaItem(mediaItem3)
prepare()
play()
}
preBtn.setOnClickListener {
if (exoPlayer.hasPreviousMediaItem()) {
exoPlayer.seekToPreviousMediaItem()
}
}
nextBtn.setOnClickListener {
if (exoPlayer.hasNextMediaItem()) {
exoPlayer.seekToNextMediaItem()
}
}
playBtn.setOnClickListener {
if (isPlaying) {
exoPlayer.pause()
playBtn.background =
ContextCompat.getDrawable(this@VideoPlayActivity, R.drawable.play)
} else {
exoPlayer.play()
playBtn.background =
ContextCompat.getDrawable(this@VideoPlayActivity, R.drawable.pause)
}
}
goBack.setOnClickListener {
finish()
}
}
private fun initListener() {
exoPlayer.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == Player.STATE_READY) {
//底部控制器处理
preBtn.visibility =
if (exoPlayer.hasPreviousMediaItem()) View.VISIBLE else View.GONE
nextBtn.visibility =
if (exoPlayer.hasNextMediaItem()) View.VISIBLE else View.GONE
setProgress()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
[email protected] = isPlaying
}
})
}
private fun formatTimestamp(timestampInSeconds: Long): String {
val minutes = timestampInSeconds / 60
val seconds = timestampInSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}
private fun setProgress() {
progressJob?.cancel()
progressJob = lifecycleScope.launch {
while (isActive) {
if (!isDragging) {
val currentPosition = exoPlayer.currentPosition
val currentTime = formatTimestamp(currentPosition / 1000)
val totalDuration = exoPlayer.duration
seekBar.max = totalDuration.toInt()
val totalTime = formatTimestamp(totalDuration / 1000)
playTime.text = "$currentTime/$totalTime"
seekBar.progress = currentPosition.toInt()
}
delay(1000)
}
}
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) { //如果是用户拖动的,则更新播放位置。
exoPlayer.seekTo(progress.toLong())
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
isDragging = true
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
isDragging = false
}
})
}
override fun onStart() {
super.onStart()
exoPlayer.play()
}
override fun onStop() {
super.onStop()
exoPlayer.pause()
}
override fun onDestroy() {
super.onDestroy()
exoPlayer.release()
}
}
效果如下:

这里使用 Media3 ExoPlayer 做了一个简单的自定义视频播放界面,当然,实际开发中可能并不只是这些自定义需求,但万变不离其宗,只要掌握了这些基操就行。