Media3 - ExoPlayer 打造音视频播放器(二)

这篇文章接上文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 做了一个简单的自定义视频播放界面,当然,实际开发中可能并不只是这些自定义需求,但万变不离其宗,只要掌握了这些基操就行。

相关推荐
GetcharZp14 小时前
Go语言实现屏幕截取+实时推流
后端·音视频开发
哔哩哔哩技术1 个月前
2025 B站春晚直播——流媒体技术助力直播体验提升与玩法创新
音视频开发
hepherd1 个月前
iOS - 音频: Core Audio - 播放
swift·音视频开发
音视频牛哥1 个月前
跨越技术藩篱,低延迟RTMP与RTSP播放器的战略意义
音视频开发·视频编码·直播
音视频牛哥1 个月前
流转时光,极致传输:大牛直播SDK跨平台RTMP播放模块的超低延迟之道
音视频开发·视频编码·直播
David凉宸1 个月前
视频融合 hls流如何对接
前端·音视频开发
音视频牛哥1 个月前
跨平台轻量级RTSP服务模块:一切源自一场小而美的坚持
音视频开发·视频编码·直播
音视频牛哥1 个月前
跨平台RTSP播放器之快于心稳于骨,毫秒之间见真章
音视频开发·视频编码·直播
音视频牛哥1 个月前
音视频行业的真相是:真正难的,是把一件事做到极致
音视频开发·视频编码·直播