0. 前言
视频播放功能由播放器来提供,而播放器的实现在音视频开发中可以算得上是一个综合性的任务,包含解码、渲染、音视频同步等多个子模块工作。音视频开发工作者最熟悉的当然是ffmpeg来实现播放器,但如果需要在Android系统上使用ffmpeg,我们不可避免地需要引入较复杂的集成任务,比如交叉编译动态库、编写JNI等,对于一些非定制需要的场景其实也没有必要。对于一些常规的播放功能,Android系统自带的MediaPlayer就可以满足我们的需要。
1. MediaPlayer介绍
MediaPlayer是Android多媒体框架中的播放器组件,使用它,开发者可以轻松地实现本地/网络媒体文件的播放、暂停、停止、跳转等操作。MediaPlayer支持多种常用的媒体文件,如mp3、mp4等,且能够很好地配合SurfaceView实现视频渲染,因此应用广泛。
2. 主要的使用流程。
graph TD
创建MediaPlayer
--> setDataSource设置输入源
--> 设置状态回调
--> prepare
--> start
--> 退出时stop
3. MediaPlayer的状态
MediaPlayer主要是使用状态机进行维护,其状态转移图如下。图中单箭头为同步调用,双箭头的为异步调用。
3.1 Idle状态、End状态
当使用new来创建MediaPlayer,或者是对已经运行的MediaPlayer调用reset()方法之后,播放器就处于Idle状态。在调用rlease()方法后,播放器就会进入End状态,在这两种状态之间的其他状态,就是MediaPlayer的生命周期。
Idle状态下 MediaPlayer 并未与具体的音视频文件或流关联,针对具体文件和流的操作都不可用。
3.2 Error状态
在MediaPlayer创建后,调用其他控制方法时,可能会出现错误,届时会进入Error状态,如果有设置过OnErrorListener.onError(),则会触发对应的回调。
如果需要从Error状态恢复到Idle状态,可以调用reset()方法。
3.3 Initialized状态
当调用setDataSource()系列方法后,播放器与具体的文件或流相关联,此时进入Initialized状态。需要注意的是,setDataSource()系列方法只能在Idle状态下调用,否则会抛出异常。
3.4 prepared状态
当MediaPlayer初始化完成,已经进入Initialized状态后,需要进入Prepared状态,才能开始播放。MediaPlayer提供了同步和异步两种方式来进行播放准备。
- 调用
prepare()同步方法,状态从Initialized -> Prepared,主要使用文件 - 调用
prepareAsync()异步方法,状态从Initialized -> Preparing -> Prepared,主要使用网络数据,需要缓冲数据
如果设置过OnPreparedListener.onPrepared(),进入Prepared时会触发对应的回调。
需要注意的是,使用MediaPlayer.create()系列方法创建的MediaPlayer实例,其创建成功时即处于Prepared状态,而不是Idle状态。
3.5 Started状态
MediaPlayer 准备完成,状态变为 Prepared 状态后,就可以开始播放了。要开始播放,必须调用MediaPlayer.start()方法。start()方法调用成功后,MediaPlayer对象会转换成Started状态。处于Started状态时,MediaPlayer的内部播放器引擎会调用用户提供的 OnBufferingUpdateListener.onBufferingUpdate()回调方法,此回调允许 app在音视频流中跟踪缓冲状态。
注意如果 MediaPlayer 对象已经处于 Started 状态,此时再调用 start() 方法不会有任何效果。我们可以调用 MediaPlayer.isPlaying() 方法检测 MediaPlayer 对象是否处于 Started 状态。
3.6 Paused状态
音视频内容开始播放,已处于Started状态后,可以暂停播放。调用 MediaPlayer.pause()方法可以暂停播放。当pause()方法调用成功后,MediaPlayer对象会进入Paused 状态。如果 MediaPlayer对象已经处于 Paused 状态,此时再调用pause()方法不会有任何效果。
Paused 状态状态下可以调用 start() 方法继续播放,继续播放开始的位置与暂停位置相同。
从Started 状态到 Paused 状态的转换(反之亦然)在播放器引擎中是异步发生的,因此调用 isPlaying() 方法获取到的状态可能有延时,需要过一段时间才能更新。对于流式内容,这个时间可能长达几秒。
3.7 Stopped状态
当调用stop()函数时,无论MediaPlayer处于Started、Paused、Prepared或PlaybackCompleted中的何种状态,都将进入Stopped状态,此时播放停止。
一旦处于Stopped状态,MediaPlayer不能再调用start()方法恢复播放,如果需要重新开始播放,则需要调用prepare()系列方法,重新进入Prepared状态。
3.8 PlaybackCompleted状态
即播放完成状态。如果MediaPlayer播放到数据流末尾时,一次播放过程完成,如果播放器没有设置过循环播放,则会进入PlaybackCompleted状态。如果有设置OnCompletionListener.onCompletion()回调,则会触发对应的回调。
循环播放可以通过setLooping()方法调用。
3.9 Seek操作
Seek操作严格来算不是状态转移,只是跳转进度控制,只是在这里一起说明。如果有设置过OnSeekComplete.onSeekComplete()回调,则会在Seek后触发对应的回调方法。
4 示例
下面通过一个例子来说明MediaPlayer的使用流程,其中也加入了进度条同步的功能。
4.1 代码
播放功能代码:
package com.zzakafool.mediaplayertest;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import android.media.MediaPlayer;
import android.media.PlaybackParams;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.widget.SeekBar;
import android.widget.Toast;
public class MediaPlayerActivity extends AppCompatActivity implements SurfaceHolder.Callback {
// SurfaceView用于渲染
private SurfaceView mSurfaceView;
private SurfaceHolder mSurfaceHolder;
// 播放器组件
private MediaPlayer mMediaPlayer;
// 进度条组件
private SeekBar mSeekBar;
// 用于同步进度
private Handler handler;
private Runnable syncSeekBarRunnable;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_mediaplayer);
initView();
}
protected void initView() {
mSurfaceView = findViewById(R.id.surfaceView);
mSurfaceHolder = mSurfaceView.getHolder();
mSurfaceHolder.addCallback(this);
mSeekBar = findViewById(R.id.seekBar);
mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
// 如果时用户拖动进度条,则调用播放器seek到指定的时间
if(mMediaPlayer != null && fromUser) {
mMediaPlayer.seekTo(progress); // 会根据seekbar设置的max值变动
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});
// 设置定时器,每100ms同步一次seekbar和播放器进度
handler = new Handler();
syncSeekBarRunnable = new Runnable() {
@Override
public void run() {
if(mMediaPlayer != null && mMediaPlayer.isPlaying()) {
int currentPosition = mMediaPlayer.getCurrentPosition();
mSeekBar.setProgress(currentPosition);
handler.postDelayed(this, 100); // 每100ms同步一次
}
}
};
}
@Override
public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
mMediaPlayer = MediaPlayer.create(getApplicationContext(), Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.big_buck_bunny), mSurfaceView.getHolder());
mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mediaPlayer) {
// prepared后可以获取到文件相关的属性,这里设置进度条的最大值为duration(ms)
mSeekBar.setMax(mMediaPlayer.getDuration());
// 打开同步的定时起
handler.post(syncSeekBarRunnable);
}
});
mMediaPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
@Override
public void onCompletion(MediaPlayer mediaPlayer) {
// 手动的循环播放,seek到0,再调start()
mediaPlayer.seekTo(0);
mediaPlayer.start();
}
});
mMediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
@Override
public boolean onError(MediaPlayer mediaPlayer, int what, int extra) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MediaPlayerActivity.this, "MediaPlayer error", Toast.LENGTH_SHORT).show();
}
});
return false;
}
});
mMediaPlayer.start();
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {
}
@Override
protected void onDestroy() {
mMediaPlayer.stop();
super.onDestroy();
}
}
对应的layout:
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat 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="wrap_content"
android:orientation="vertical"
tools:context=".MediaPlayerActivity">
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:layout_marginTop="10dp" />
<SeekBar
android:id="@+id/seekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</androidx.appcompat.widget.LinearLayoutCompat>