本章介绍App开发常用的一些多媒体处理技术,主要包括:如何录制和播放音频,如何使用传统相机拍照和录像,如何截取视频画面,如何使用增强相机拍照和录像。
音频录播
本节介绍Android对音频的录播操作,内容包括如何使用系统录音机录制音频、如何利用MediaPlayer播放音频、如何使用MediaRecorder录制音频。
使用系统录音机录制音频
手机自带的系统相机,也有自带的系统录音机,录音机对应的意图动作为MediaStore.Audio.Media.RECORD_SOUND_ACTION,只要在调用startActivityForResult之前指定该动作,就会自动跳转到系统的录音机界面。下面便是前往系统录音机的跳转代码例子:
// 下面打开系统自带的录音机
Intent intent = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION);
startActivityForResult(intent, RECORDER_CODE); // 跳到录音机页面
注意上面的RECORDER_CODE是自定义的一个常量值,表示录音来源,目的是在onActivityResult方法中区分唯一的请求码。接着重写活动页面的onActivityResult方法,添加以下的回调代码获取录制好的音频:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
super.onActivityResult(requestCode, resultCode, intent);
if (resultCode==RESULT_OK && requestCode==RECORDER_CODE){
mAudioUri = intent.getData(); // 获得录制好的音频uri
String filePath = String.format("%s/%s.mp3",
getExternalFilesDir(Environment.DIRECTORY_MUSIC), "audio_"+ DateUtil.getNowDateTime());
FileUtil.saveFileFromUri(this, mAudioUri, filePath); // 保存为临时文件
tv_audio.setText("录制完成的音频地址为:"+mAudioUri.toString());
iv_audio.setVisibility(View.VISIBLE);
}
}
从以上代码可知,录制完的音频路径就在返回意图的getData当中,那么怎样验证这个路劲保存的是音频呢?当然是听听该音频能否正常播放就对了。所谓好事成双,既有录音机,又有收音机,音频自然由系统自带的收音机播放了。若想自动跳转到收音机界面,关键是把数据类型设置为音频,系统才知晓原来是要打开音频,这活还是交给收音机吧。打开系统收音机的跳转代码如下:
// 下面打开系统自带的收音机
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(mAudioUri, "audio/*"); // 类型为音频
startActivity(intent); // 跳到收音机页面
接下来通过实验来看录音与播音的完整过程,点击“打开录音机”按钮之后,跳转到如下图所示的录音机界面。
点击底部的圆形按钮开始录音,稍等几秒再次点击该按钮结束录音,此时屏幕底部弹出如下图所示的选择对话框。
点击选择对话框中的“使用此录音”选线,回到测试App界面,如下图所示,可见回调代码成功获得刚录制得音频路径。
点击页面上的三角播放按钮,跳转到如下图的收音机界面,同时收音机开始播放音频。
利用MediaPlayer播放音频
尽管让App跳转到收音机界面就能播放音频,但是通常App都不希望用户离开自身页面,何况播音本来仅是一个小功能,完全可以一边播放音频一边操作界面。若要在App内部自己播音,便用到了媒体播放器MediaPlayer,不过在播放音频之前,得先想办法找到音频文件才行。通过内容解析器能够从媒体库查找图片文件,同样也能从媒体库查找音频文件,只要把相关条件换成音频种类就成,例如把媒体库得Uri路径从相册换成音频库,把媒体库的查找结果从相册字段换作音频字段等。为此另外定义并声明音频类型的实体对象,声明代码如下:
private List<AudioInfo> mAudioList = new ArrayList<AudioInfo>(); // 音频列表
private Uri mAudioUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; // 音频库的Uri
private String[] mAudioColumn = new String[]{ // 媒体库的字段名称数组
MediaStore.Audio.Media._ID, // 编号
MediaStore.Audio.Media.TITLE, // 标题
MediaStore.Audio.Media.DURATION, // 播放时长
MediaStore.Audio.Media.SIZE, // 文件大小
MediaStore.Audio.Media.DATA}; // 文件路径
private MediaPlayer mMediaPlayer = new MediaPlayer(); // 媒体播放器
接着通过内容解析器系统的音频库,把符合条件的音频记录依次添加到音频列表,下面便是从媒体库加载音频文件列表的代码例子:
// 加载音频列表
private void loadAudioList() {
mAudioList.clear(); // 清空音频列表
// 通过内容解析器查询音频库,并返回结果集的游标。记录结果按照修改时间降序返回
Cursor cursor = getContentResolver().query(mAudioUri, mAudioColumn,
null, null, "date_modified desc");
if (cursor != null) {
// 下面遍历结果集,并逐个添加到音频列表。简单起见只挑选前十个音频
for (int i=0; i<10 && cursor.moveToNext(); i++) {
AudioInfo audio = new AudioInfo(); // 创建一个音频信息对象
audio.setId(cursor.getLong(0)); // 设置音频编号
audio.setTitle(cursor.getString(1)); // 设置音频标题
audio.setDuration(cursor.getInt(2)); // 设置音频时长
audio.setSize(cursor.getLong(3)); // 设置音频大小
audio.setPath(cursor.getString(4)); // 设置音频路径
mAudioList.add(audio); // 添加至音频列表
}
cursor.close(); // 关闭数据库游标
}
}
找到若干音频文件之后,还要设法利用MediaPlayer来播音。MediaPlayer顾名思义叫作媒体播放器,它既能播放音频也能播放视频,其常用方法说明如下:
- reset:重置播放器。
- prepare:准备播放。
- start:开始播放。
- pause:暂停播放。
- stop:停止播放。
- create:创建指定Uri的播放器。
- setDataSource:设置播放器数据来源的文件路径。create与setDataSource两个方法只需调用一个。
- setVolume:设置音量。两个参数分别是左声道和右声道的音量,取值0~1。
- setAudioStreamType:设置音频流的类型。音频流类型的取值说明见下表。
AudioManager类的铃音类型 | 铃声名称 | 说明 |
---|---|---|
STREAM_VOICE_CALL | 通话音 | |
STREAM_SYSTEM | 系统音 | |
STREAM_RING | 铃声 | 来电与收到短信的铃声 |
STREAM_MUSIC | 媒体音 | 音乐、视频、游戏等的声音 |
STREAM_ALARM | 闹钟音 | |
STREAM_NOTIFICATION | 通知音 |
- setLooping:设置是否循环播放。true表示循环播放,false表示只播放一次。
- isPlaying:判断是否正在播放。
- getCurrentPosition:获取当前播放进度所在的位置。
- getDuration:获取播放时长,单位为毫秒。
MediaPlayer提供的方法虽多,基本的应用场景只有两个:一个是播放指定音频文件,另一个是在退出页面时释放媒体资源。其中播放音频的场景需要经历下列步骤:重置播放器->设置媒体文件路径->准备播放->开始播放。对应的播放代码示例如下:
mMediaPlayer.reset(); // 重置媒体播放器
// mMediaPlayer.setVolume(0.5f, 0.5f); // 设置音量,可选
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); // 设置音频流的类型为音乐
try {
mMediaPlayer.setDataSource(audio.getPath()); // 设置媒体数据的文件路径
mMediaPlayer.prepare(); // 媒体播放器准备就绪
mMediaPlayer.start(); // 媒体播放器开始播放
} catch (Exception e) {
e.printStackTrace();
}
如果没把音频放入后台服务中播放,那么在退出活动页面之时应当主动释放媒体资源,以便提高系统运行效率。此时可以重写活动的onDestroy方法,在该方法内部补充下面的操作代码:
if (mMediaPlayer.isPlaying()) { // 是否正在播放
mMediaPlayer.stop(); // 结束播放
}
mMediaPlayer.release(); // 释放媒体播放器
当然,上述的两个场景之时两种最基础的运用,除此之外,还存在其他业务场合,包括但不限于:实时刷新当前的播放进度、将音频拖动到指定位置再播放、播放完毕之时提醒用户等,详细的演示代码参见AudioPlayActivity.java。下面是使用MediaPlayer播放音频的界面效果。其中左侧展示了刚打开的初始界面,此时App自动查找并罗列最新的音频文件;点击其中一项音频,App便开始播放该音频,同时下方实时显示播放进度如右侧图片所示。
利用MediaRecorder录制音频
与媒体播放器相对应,Android提供了媒体录制器MediaRecorder,它既能录制音频也能录制视频。使用MediaRecorder可以在当前页面直接录音,而不必跳转到系统自带的录音机界面。MediaRecorder的常用方法说明如下:
- reset:重置录制器。
- prepare:准备录制。
- start:开始录制。
- stop:结束录制。
- release:释放录制器。
- setMaxDuration:设置可录制的最大时长,单位为毫秒(ms)。
- setMaxFileSize:设置可录制的最大文件大小,单位为字节(B)。setMaxDuration与setMaxFileSize设置其一即可。
- setOutputFile:设置输出文件的保存路径。
- setAudioSource:设置音频来源。一般使用麦克风AudioSource.MIC。
- setOutputFormat:设置媒体输出格式。媒体输出格式的取值说明见下表。
OutputFormat类的输出格式 | 格式分类 | 扩展名 | 格式说明 |
---|---|---|---|
AMR_NB | 音频 | .arm | 窄带格式 |
AMR_WB | 音频 | .arm | 宽带格式 |
AAC_ADTS | 音频 | .aac | 高级的音频传输流格式 |
MPEG_4 | 视频 | .mp4 | MPEG4格式 |
THREE_GPP | 视频 | .3gp | 3GP格式 |
- setAudioEncoder:设置音频编码器。音频编码器的取值说明见下表。注意,该方法应在setOutputFormat方法之后执行,否则会抛出异常。
AudioEncoder类的音频编码器 | 说明 |
---|---|
AMR_NB | 窄带编码 |
AMR_WB | 宽带编码 |
AAC | 低复杂度的高级编码 |
HE_AAC | 高效率的高级编码 |
AAC_ELD | 高效率的高级编码 |
- setAudioSamplingRate:设置音频的采样率,单位为千赫兹(kHz)。AMR_NB格式默认为8kHz,AMR_WB格式默认为16kHz。
- setAudioChannels:设置音频每秒录制的字节数。数值越大音频越清晰。
MediaRecorder提供的方法虽多,基本的应用场景只有两个:一个是开始录制媒体文件,另一个是停止录制媒体文件。其中录制音频的场景需要经历下列步骤:重置录制器->设置媒体文件的路径->准备录制->开始录制,对应的录制代码示例如下:
// 获取本次录制的媒体文件路径
mRecordFilePath = MediaUtil.getRecordFilePath(this, "RecordAudio", ".amr");
// 下面是媒体录制器的处理代码
mMediaRecorder.reset(); // 重置媒体录制器
mMediaRecorder.setOnInfoListener(this); // 设置媒体录制器的信息监听器
mMediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); // 设置音频源为麦克风
mMediaRecorder.setOutputFormat(mOutputFormat); // 设置媒体的输出格式。该方法要先于setAudioEncoder调用
mMediaRecorder.setAudioEncoder(mAudioEncoder); // 设置媒体的音频编码器
mMediaRecorder.setMaxDuration(mDuration * 1000); // 设置媒体的最大录制时长
mMediaRecorder.setOutputFile(mRecordFilePath); // 设置媒体文件的保存路径
try {
mMediaRecorder.prepare(); // 媒体录制器准备就绪
mMediaRecorder.start(); // 媒体录制器开始录制
} catch (Exception e) {
e.printStackTrace();
}
至于停止录制操作,直接调用stop方法即可。当然,在退出活动页面之时,还需调用release方法释放录制资源。注意到上述的录制代码引用了若干变量,包括输出格式mOutputFormat、音频编码器mAudioEncoder、最大录制时长mDuration等,这些参数决定了音频文件的音效质量和文件大小,详细的演示例子参见代码MediaRecorderActivity.java。
运行测试App,保持默认的录制参数,点击“开始录制”按钮,正在录音的界面如下图左侧所示;稍等片刻录音完成的界面如下图右侧所示,此时成功保存录制好的音频文件,点击下方的三角播放按钮,就能通过MediaPlayer播音了。
传统摄制
本节介绍Android对照片和视频的传统摄制操作,内容包括如何使用系统相机拍摄照片(含缩略图和原始图两种方式)、如何使用系统摄像机录制视频、如何利用视频视图与媒体控制条播放视频、如何通过媒体检索工具截取视频画面。
使用系统相机拍摄照片
俗话说“眼睛是心灵的窗户”,那么摄像头便是手机的窗户了,一部手机美不美,很大程度上要看它的摄像头,因为好的摄像头才能拍摄出美丽的照片。对于手机拍照的App开发而言,则有两种实现方式:一种通过Camera工具联合表面视图SurfaceView自行规划编码细节;另一种是借助系统相机自动拍照。考虑到多数场景对图片并无特殊要求,因而使用系统相机更加方便快捷。
调用系统相机的方式也有初级与高级之分,倘若仅仅想看个大概,那么一张缩略图便已足够。下面便是打开相机的代码例子:
// 下面通过系统相机拍照只能获得缩略图
Intent photoIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
startActivityForResult(photoIntent, THUMBNAIL_CODE); // 打开系统相机
注意上面的THUMBNAIL_CODE是自定义的一个常量值,表示缩略图来源,目的是在onActivityResult方法中区分唯一的请求代码。接着重写胡活动页面的onActivityResult方法,添加以下的回调代码获取缩略图对象:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
super.onActivityResult(requestCode, resultCode, intent);
if (RESULT_OK == resultCode && THUMBNAIL_CODE == requestCode) {
// 缩略图放在返回意图中的data字段,将其取出转成位图对象即可
Bundle extras = intent.getExtras();
Bitmap bitmap = (Bitmap)extras.get("data");
iv_photo.setImageBitmap(bitmap); // 设置图像视图的位图对象
}
}
运行App,打开系统相册,此时定格的画面如下左图所示。点击屏幕右上角的打勾图标,返回App界面如下图右侧所示,果然显示刚才拍照的缩略图。
通过系统相机拍照获得缩略图就是这么简单,只是缩略图不够清晰,马马虎虎浏览一下尚可,要看得细致入微确实不能够了。若想得到高清大图,势必采取系统相机得高级用法,为此事先声明一个图片得Uri对象,声明代码如下:
private Uri mImageUri; // 图片的路径对象
接着在打开系统相机之前,传入图片得路径对象,表示拍好得图片保存在这个路径,具体得操作代码如下(注意安卓10得适配处理代码):
// Android10开始必须由系统自动分配路径,同时该方式也能自动刷新相册
ContentValues values = new ContentValues();
// 指定图片文件的名称
values.put(MediaStore.Images.Media.DISPLAY_NAME, "photo_"+DateUtil.getNowDateTime());
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg"); // 类型为图像
// 通过内容解析器插入一条外部内容的路径信息
mImageUri = getContentResolver().insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// 下面通过系统相机拍照可以获得原始图
photoIntent.putExtra(MediaStore.EXTRA_OUTPUT, mImageUri);
startActivityForResult(photoIntent, ORIGINAL_CODE); // 打开系统相机
以上的ORIGINAL_CODE依然是自定义得请求代码,表示原始图来源,然后重写活动页面的onActivityResult方法,补充下述的分支处理代码:
if (RESULT_OK == resultCode && ORIGINAL_CODE == requestCode) {
// 根据指定图片的Uri,获得自动缩小后的位图对象
Bitmap bitmap = BitmapUtil.getAutoZoomImage(this, mImageUri);
iv_photo.setImageBitmap(bitmap); // 设置图像视图的位图对象
}
因为之前已经把图片的路径对象传给系统相机了,所以这里可以直接设置图像视图的路径对象,无须再去解析什么包裹信息。
重新运行测试App,打开系统相机后拍照,此时定额的画面如下左图。仍旧点击屏幕右上角的打勾图标,返回App界面如下右图所示,果然成功展示了拍摄的高清大图。
使用系统摄像机录制视频
与音频类似,通过系统摄像机可以很方便地录制视频,只要指定摄像动作为MediaStore.ACTION_VIDEO_CAPTURE即可。当然,也能事先设定下列的摄像参数:
- MediaStore.EXTRA_VIDEO_QUALITY:用于设定视频质量。
- MediaStore.EXTRA_SIZE_LIMIT:用于设定文件大小的上限。
- MediaStore.EXTRA_DURATION_LIMIT:用于设定视频时长的上限。
下面是跳转到系统摄像机的代码例子:
// 声明一个活动结果启动器对象
private ActivityResultLauncher launcher = registerForActivityResult (
new ActivityResultContracts.TakeVideo(), bitmap -> {
tv_video.setText("录制完成的视频地址为:"+mVideoUri.toString());
rl_video.setVisibility(View.VISIBLE);
if (bitmap == null) {
// 获取视频文件的某帧图片
bitmap = MediaUtil.getOneFrame(this, mVideoUri, 1000);
}
iv_video.setImageBitmap(bitmap);
});
// 开始录制视频
private void takeVideo() {
// Android10开始必须由系统自动分配路径,同时该方式也能自动刷新相册
ContentValues values = new ContentValues();
// 指定图片文件的名称
values.put(MediaStore.Video.Media.DISPLAY_NAME, "video_"+DateUtil.getNowDateTime());
values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); // 类型为视频
// 通过内容解析器插入一条外部内容的路径信息
mVideoUri = getContentResolver().insert(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
launcher.launch(mVideoUri);
}
视频录制完成,最好能够预览视频的摄制画面,所以上面代码调用了getOneFrame方法获取视频文件的某帧图片,查看该帧图像即可大致了解视频内容。抽取视频帧图的getOneFrame方法代码如下:
// 获取视频文件中的某帧图片。pos为毫秒时间
public static Bitmap getOneFrame(Context ctx, Uri uri, int pos) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
// 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
Bitmap bitmap = retriever.getFrameAtTime(pos * 1000);
return bitmap;
}
有了视频文件的Uri之后,就能利用系统自带的播放器观看视频了。同样设置意图动作Intent.ACTION_VIEW,并指定数据类型为视频,以下几行代码即可打开视频播放器:
// 创建一个内容获取动作的意图(准备跳到系统播放器)
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(mVideoUri, "video/*"); // 类型为视频
startActivity(intent); // 打开系统的视频播放器
运行App,点击“打开摄像机”按钮之后,跳转到如下图左侧所示的系统摄像界面,点击界面下方中央的圆形按钮开始录像,稍等几秒再次按下该按钮,或者等待EXTRA_DURATION_LIMIT设定的时长到达,此时摄像结束的界面如下图右侧所示。
点击录像界面右上角的打勾图标,回到App的演示界面,发现原页面展示了已枯枝视频的快照图像。单击该快照图片表示期望播放视频,即可播放录制的视频。
利用视频视图与媒体控制条播放视频
通过专门的播放器固然能够播放视频,但要离开当前App跳转到播放器界面才行,因为视频播放不算很复杂的功能,人们更希望内嵌在当前App界面,所以Android提供了名为视频视图(VideoView)的播放控件,该控件允许图像视图那样划出一块界面展示视频,同时还支持对视频进行播放控制,为开发者定制视频操作提供了便利。
下面是VideoView的常用方法:
- setVideoURI:设置视频文件的URI路径。
- setVideoPath:设置视频文件的字符串路径。
- setMediaController:设置媒体控制条的对象。
- start:开始播放视频。
- pause:暂停播放视频。
- resume:恢复播放视频。
- suspend:结束播放并释放资源。
- getDuration:获得视频的总时长,单位为毫秒。
- getCurrentPosition:获得当前的播放位置。返回值若等于总时长,表示播放到了末尾。
- isPlaying:判断视频是否正在播放。
由于VideoView只显示播放界面,没显示控制按钮和进度条,因此在实际开发中需要给她配备媒体控制条MediaController。该控制条支持基本的播放控制操作,包括:显示当前的播放进度、拖动到指定位置播放、暂停播放与恢复播放、查看视频的总时长和已播放时长、对视频做快进或快退操作等。
下面是MediaController的常用方法说明:
- setMediaPlayer:设置媒体播放器的对象,也就是指定某个VideoView。
- show:显示媒体控制条。
- hide:隐藏媒体控制条。
- isShowing:判断媒体控制条是否正在显示。
将媒体控制条与视频图集成起来的话,一般让媒体控制条固定放在视频视图的底部。此时无须在XML文件中添加MediaController节点,只需要添加VideoView节点,然后在Java代码中将媒体控制条附着于视频视图即可。具体的集成步骤分为下列4步:
- 由视频对象调用setVideoURI方法指定视频文件。
- 创建一个媒体控制条,并由视频视图对象调用setMediaController方法关联该控制条。
- 由控制条对象调用setMediaPlayer方法,将媒体播放器设置为该视频视图。
- 调用视频视图对象的start方法,开始播放视频。
接下来实验看看如何通过视频视图播放视频。首先创建测试活动页面,在该页面的XML文件中添加VideoView节点,完整的XML内容如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/btn_choose"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="打开相册播放视频"
android:textColor="@color/black"
android:textSize="17sp" />
<VideoView
android:id="@+id/vv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
然后往该页面的活动代码补充选视频库之后的回调逻辑,也就是重写registerForActivityResult回调方法,在该方法内部设置视频图的视频路径,关联媒体控制条,再调用时评视图的start方法播放视频。详细的活动页面代码示例如下:
public class VideoPlayActivity extends AppCompatActivity {
private final static String TAG = "VideoPlayActivity";
private VideoView vv_content; // 声明一个视频视图对象
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_video_play);
// 从布局文件中获取名叫vv_content的视频视图
vv_content = findViewById(R.id.vv_content);
// 注册一个善后工作的活动结果启动器,获取指定类型的内容
ActivityResultLauncher launcher = registerForActivityResult(
new ActivityResultContracts.GetContent(), uri -> {
if (uri != null) {
playVideo(uri); // 播放视频
}
});
findViewById(R.id.btn_choose).setOnClickListener(v -> launcher.launch("video/*"));
}
private void playVideo(Uri uri) {
vv_content.setVideoURI(uri); // 设置视频视图的视频路径
MediaController mc = new MediaController(this); // 创建一个媒体控制条
vv_content.setMediaController(mc); // 给视频视图设置相关联的媒体控制条
mc.setMediaPlayer(vv_content); // 给媒体控制条设置相关联的视频视图
vv_content.start(); // 视频视图开始播放
}
}
运行测试App,打开初始的视频界面如下图最左侧所示,此时按钮下方没有黑漆漆的一片都是视频视图区域;点击“打开相册播放视频”按钮从视频库选择视频回来,该界面立即开始播放选中的视频,如下图中间图片;在视频区域轻轻点击,此时视频下方弹出一排媒体控制条,如下图最右侧所示,可见媒体控制条上半部分有快进、暂停、快退 3个按钮,下半部分展示了当前播放时长、播放进度条、视频总时长。
截取视频的某帧画面
不管是系统相册还是视频网站,在某个视频尚未播放的时候都会显示一张预览图片,该图片通常是视频中的某个画面。Android从视频中截取某帧画面,用到了媒体检索工具MediaMetadataRetriever,它的常见方法分别说明如下:
- setDataSource:将指定URI设置为媒体数据源。
- extractMetadata:获得视频的播放时长。
- getFrameAtIndex:获取指定索引的帧图。
- getFrameAtTime:获取指定时间的帧图,时间单位为微秒。
- release:释放媒体资源。
下面是利用MediaMetadataRetriever从视频截取某帧位图的示例代码:
// 获取视频文件中的某帧图片。pos为毫秒时间
public static Bitmap getOneFrame(Context ctx, Uri uri, int pos) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
// 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
Bitmap bitmap = retriever.getFrameAtTime(pos * 1000);
return bitmap;
}
若要从视频中截取一串时间相邻的画面,则可依据相邻的时间点调用getFrameAtTime方法,依次获得每帧位图再保存到存储卡。连续截取视频画面的示例代码如下:
// 获取视频文件中的图片帧列表。beginPos为毫秒时间,count为待获取的帧数量
public static List<String> getFrameList(Context ctx, Uri uri, int beginPos, int count) {
String videoPath = uri.toString();
String videoName = videoPath.substring(videoPath.lastIndexOf("/")+1);
if (videoName.contains(".")) {
videoName = videoName.substring(0, videoName.lastIndexOf("."));
}
List<String> pathList = new ArrayList<>();
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(ctx, uri); // 将指定Uri设置为媒体数据源
// 获得视频的播放时长
String duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
int dura_int = Integer.parseInt(duration)/1000;
for (int i=0; i<dura_int-beginPos/1000 && i<count; i++) { // 最多只取前多少帧
String path = String.format("%s/%s_%d.jpg",
ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString(), videoName, i);
if (beginPos!=0 || !new File(path).exists()) {
// 获取指定时间的帧图,注意getFrameAtTime方法的时间单位是微秒
Bitmap frame = retriever.getFrameAtTime(beginPos*1000 + i*1000*1000);
int ratio = frame.getWidth()/500+1;
Bitmap small = BitmapUtil.getScaleBitmap(frame, 1.0/ratio);
BitmapUtil.saveImage(path, small); // 把位图保存为图片文件
}
pathList.add(path);
}
return pathList;
}
运行测试该App,打开视频文件播放一阵后,点击“截取当前帧”按钮,可观察到截取结果如下图左侧所示;再点击“截取后九段”按钮,随后会跳转到各帧画面的列表项,成功截取到视频画面,如下图右侧所示。
增强摄制
本节介绍Android对相片和视频录制与播放的高级用法,内容包括如何使用增强CameraX库拍摄相片、如何使用增强的CameraX库录制视频、如何使用新型播放器ExoPlayer播放各类视频(网络视频和带字幕视频)。
使用CameraX拍照
Android的SDK一开始就自带了相机工具Camera,从Android 5.0开始又推出了升级版的Camera2,然而不管是初代的Camera还是二代的Camera2,编码过程都比较繁琐,对于新手而言有点艰深。为此谷歌公司再Jetpack库中集成了增强的相机库CameraX,想让相机编码(包括拍照和录像)变得更加方便。CameraX基于Camera2开发,它提供一致易用的API接口,还解决了设备兼容性问题,从而减少了编码工作量。
不管是拍照还是录像,都要在AndroidManifest.xml中添加相机权限,还要添加存储卡访问权限,代码如下:
<!-- 相机 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
由于CameraX来自Jetpack库,因此要修改模块build.gradle.kts,往dependencies节点添加以下几行配置,表示导入指定版本的CameraX库:
implementation ("androidx.camera:camera-core:1.0.2")
implementation ("androidx.camera:camera-camera2:1.0.2")
implementation ("androidx.camera:camera-lifecycle:1.0.2")
implementation ("androidx.camera:camera-view:1.0.0-alpha32")
使用CameraX拍照之前先要初始化相机,包括界面预览以及参数设定等,具体的初始化步骤说明如下:
- 准备一个预览视图对象PreviewView,并添加至当前界面。
- 获取相机提供器对象ProcessCameraProvider。
- 构建预览对象Preview,指定预览的宽高比例。
- 构建摄像头选择器对象CameraSelector,指定使用前置摄像头还是后置摄像头。
- 构建图像捕捉器对象ImageCapture,分别设置捕捉模式、旋转角度、宽高比例、闪光模式等拍照参数。
- 调用相机提供器对象的bindToLifecyccle方法,把相机选择器、预览视图、图像捕捉绑定到相机提供器。
- 调用预览视图对象的setSurfaceProvider方法,设置预览视图的表面提供器。
把上述的初始化步骤串起来,写到一个自定义的相机视图控件中,便形成了以下的CameraX初始化代码:
private Context mContext; // 声明一个上下文对象
private PreviewView mCameraPreview; // 声明一个预览视图对象
private CameraSelector mCameraSelector; // 声明一个摄像头选择器
private Preview mPreview; // 声明一个预览对象
private ProcessCameraProvider mCameraProvider; // 声明一个相机提供器
private ImageCapture mImageCapture; // 声明一个图像捕捉器
private VideoCapture mVideoCapture; // 声明一个视频捕捉器
private ExecutorService mExecutorService; // 声明一个线程池对象
private LifecycleOwner mOwner; // 声明一个生命周期拥有者
private int mCameraMode = MODE_PHOTO; // 0拍照,1录像
private int mCameraType = CameraSelector.LENS_FACING_BACK; // 摄像头类型,默认后置摄像头
private int mAspectRatio = AspectRatio.RATIO_16_9; // 宽高比例。RATIO_4_3表示宽高3比4;RATIO_16_9表示宽高9比16
private int mFlashMode = ImageCapture.FLASH_MODE_AUTO; // 闪光灯模式
private String mMediaDir; // 媒体保存目录
public CameraXView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
mCameraPreview = new PreviewView(mContext); // 创建一个预览视图
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
mCameraPreview.setLayoutParams(params);
addView(mCameraPreview); // 把预览视图添加到界面上
mExecutorService = Executors.newSingleThreadExecutor(); // 创建一个单线程线程池
mMediaDir = mContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString();
}
// 打开相机
public void openCamera(LifecycleOwner owner, int cameraMode, OnStopListener sl) {
mOwner = owner;
mCameraMode = cameraMode;
mStopListener = sl;
mHandler.post(() -> initCamera()); // 初始化相机
}
// 初始化相机
private void initCamera() {
ListenableFuture future = ProcessCameraProvider.getInstance(mContext);
future.addListener(() -> {
try {
mCameraProvider = (ProcessCameraProvider) future.get();
resetCamera(); // 重置相机
} catch (Exception e) {
e.printStackTrace();
}
}, ContextCompat.getMainExecutor(mContext));
}
// 重置相机
private void resetCamera() {
int rotation = mCameraPreview.getDisplay().getRotation();
// 构建一个摄像头选择器
mCameraSelector = new CameraSelector.Builder().requireLensFacing(mCameraType).build();
// 构建一个预览对象
mPreview = new Preview.Builder()
.setTargetAspectRatio(mAspectRatio) // 设置宽高比例
.build();
// 构建一个图像捕捉器
mImageCapture = new ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) // 设置捕捉模式
.setTargetRotation(rotation) // 设置旋转角度
.setTargetAspectRatio(mAspectRatio) // 设置宽高比例
.setFlashMode(mFlashMode) // 设置闪光模式
.build();
if (mCameraMode == MODE_RECORD) { // 录像
// 构建一个视频捕捉器
mVideoCapture = new VideoCapture.Builder()
.setTargetAspectRatio(mAspectRatio) // 设置宽高比例
.setVideoFrameRate(60) // 设置视频帧率
.setBitRate(3 * 1024 * 1024) // 设置比特率
.setTargetRotation(rotation) // 设置旋转角度
.setAudioRecordSource(MediaRecorder.AudioSource.MIC)
.build();
}
bindCamera(MODE_PHOTO); // 绑定摄像头
// 设置预览视图的表面提供器
mPreview.setSurfaceProvider(mCameraPreview.getSurfaceProvider());
}
// 绑定摄像头
private void bindCamera(int captureMode) {
mCameraProvider.unbindAll(); // 重新绑定前要先解绑
try {
if (captureMode == MODE_PHOTO) { // 拍照
// 把相机选择器、预览视图、图像捕捉器绑定到相机提供器的生命周期
Camera camera = mCameraProvider.bindToLifecycle(
mOwner, mCameraSelector, mPreview, mImageCapture);
} else if (captureMode == MODE_RECORD) { // 录像
// 把相机选择器、预览视图、视频捕捉器绑定到相机提供器的生命周期
Camera camera = mCameraProvider.bindToLifecycle(
mOwner, mCameraSelector, mPreview, mVideoCapture);
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 关闭相机
public void closeCamera() {
mCameraProvider.unbindAll(); // 解绑相机提供器
mExecutorService.shutdown(); // 关闭线程池
}
初始化相机后,即可调用图像捕捉器的takePicture方法拍摄照片了,拍照代码示例如下:
private String mPhotoPath; // 照片保存路径
// 获取照片的保存路径
public String getPhotoPath() {
return mPhotoPath;
}
// 开始拍照
public void takePicture() {
mPhotoPath = String.format("%s/%s.jpg", mMediaDir, DateUtil.getNowDateTime());
ImageCapture.Metadata metadata = new ImageCapture.Metadata();
// 构建图像捕捉器的输出选项
ImageCapture.OutputFileOptions options = new ImageCapture.OutputFileOptions.Builder(new File(mPhotoPath))
.setMetadata(metadata).build();
// 执行拍照动作
mImageCapture.takePicture(options, mExecutorService, new ImageCapture.OnImageSavedCallback() {
@Override
public void onImageSaved(ImageCapture.OutputFileResults outputFileResults) {
BitmapUtil.notifyPhotoAlbum(mContext, mPhotoPath); // 通知相册来了张新图片
mStopListener.onStop("已完成拍摄,照片保存路径为"+mPhotoPath);
}
@Override
public void onError(ImageCaptureException exception) {
mStopListener.onStop("拍摄失败,错误信息为:"+exception.getMessage());
}
});
}
然后在App代码中集成新定义的增强相机控件,先在布局文件中添加CameraXView节点,代码如下:
<com.example.chapter14.widget.CameraXView
android:id="@+id/cxv_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
再给Java代码补充CameraXView对象的初始化以及拍照动作,其中关键代码示例如下:
private CameraXView cxv_preview; // 声明一个增强相机视图对象
private View v_black; // 声明一个视图对象
private ImageView iv_photo; // 声明一个图像视图对象
private final Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
// 初始化相机
private void initCamera() {
// 打开增强相机,并指定停止拍照监听器
cxv_preview.openCamera(this, CameraXView.MODE_PHOTO, (result) -> {
runOnUiThread(() -> {
iv_photo.setEnabled(true);
Toast.makeText(this, result, Toast.LENGTH_SHORT).show();
});
});
}
// 处理拍照动作
private void dealPhoto() {
iv_photo.setEnabled(false);
v_black.setVisibility(View.VISIBLE);
cxv_preview.takePicture(); // 拍摄照片
mHandler.postDelayed(() -> v_black.setVisibility(View.GONE), 500);
}
运行App,点击拍照图标,观察到增强相机的拍照效果如下图所示。其中,左图为准备拍照时的预览界面,右图为拍照结束后的观赏界面。
使用CameraX录像
要通过CameraX事先录像功能的话,初始化相机的步骤与拍照时大小异同,区别在于增加了对视频捕捉器VideoCapture的处理。需要修改的代码主要有三个地方,分别说明如下:
- 第一个地方是在build.gradle.kts里补充声明录音权限,完整的权限声明配置如下:
<!-- 相机 -->
<uses-permission android:name="android.permission.CAMERA" /> <
!-- 录音 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
- 第二个地方是在重置相机的resetCamera方法中,构建完图像捕捉器对象后,还要构建视频捕捉器对象,并设置视频的宽高比例、视频帧率、比特率(视频每秒录制的比特数)、旋转角度等录制参数。视频捕捉器的构建代码示例如下:
if (mCameraMode == MODE_RECORD) { // 录像
// 构建一个视频捕捉器
mVideoCapture = new VideoCapture.Builder()
.setTargetAspectRatio(mAspectRatio) // 设置宽高比例
.setVideoFrameRate(60) // 设置视频帧率
.setBitRate(3 * 1024 * 1024) // 设置比特率
.setTargetRotation(rotation) // 设置旋转角度
.setAudioRecordSource(MediaRecorder.AudioSource.MIC)
.build();
}
- 第三个地方是在绑定摄像头的bindCamera方法中,对于录像操作来说,需要把视频捕捉器绑定到相机提供器绑定到相机提供器的生命周期,而非绑定图像捕捉器。绑定视频捕捉器的代码示例如下:
// 把相机选择器、预览视图、图像捕捉器绑定到相机提供器的生命周期
Camera camera = mCameraProvider.bindToLifecycle(
mOwner, mCameraSelector, mPreview, mImageCapture);
初始化相机之后,即可调用视频捕捉器的startRecording方法开始录像,或者调用stopRecording方法停止录像。录像代码如下:
private String mVideoPath; // 视频保存路径
private int MAX_RECORD_TIME = 15; // 最大录制时长,默认15秒
// 获取视频的保存路径
public String getVideoPath() {
return mVideoPath;
}
// 开始录像
public void startRecord(int max_record_time) {
MAX_RECORD_TIME = max_record_time;
bindCamera(MODE_RECORD); // 绑定摄像头
mVideoPath = String.format("%s/%s.mp4", mMediaDir, DateUtil.getNowDateTime());
VideoCapture.Metadata metadata = new VideoCapture.Metadata();
// 构建视频捕捉器的输出选项
VideoCapture.OutputFileOptions options = new VideoCapture.OutputFileOptions.Builder(new File(mVideoPath))
.setMetadata(metadata).build();
// 开始录像动作
mVideoCapture.startRecording(options, mExecutorService, new VideoCapture.OnVideoSavedCallback() {
@Override
public void onVideoSaved(VideoCapture.OutputFileResults outputFileResults) {
mHandler.post(() -> bindCamera(MODE_PHOTO));
mStopListener.onStop("录制完成的视频路径为"+mVideoPath);
}
@Override
public void onError(int videoCaptureError, String message, Throwable cause) {
mHandler.post(() -> bindCamera(MODE_PHOTO));
mStopListener.onStop("录制失败,错误信息为:"+cause.getMessage());
}
});
// 限定时长到达之后自动停止录像
mHandler.postDelayed(() -> stopRecord(), MAX_RECORD_TIME*1000);
}
// 停止录像
public void stopRecord() {
mVideoCapture.stopRecording(); // 视频捕捉器停止录像
}
当然,录像功能也要先在布局文件中添加CameraXView节点。为了方便观察当前已录制的时长,还可以在布局文件中添加计时器节点chronometer。接着给Java代码补充CameraXView对象的初始化以及录像动作,其中关键代码示例如下:
private CameraXView cxv_preview; // 声明一个增强相机视图对象
private Chronometer chr_cost; // 声明一个计时器对象
private ImageView iv_record; // 声明一个图像视图对象
private boolean isRecording = false; // 是否正在录像
// 初始化相机
private void initCamera() {
// 打开增强相机,并指定停止录像监听器
cxv_preview.openCamera(this, CameraXView.MODE_RECORD, (result) -> {
runOnUiThread(() -> {
chr_cost.setVisibility(View.GONE);
chr_cost.stop(); // 停止计时
iv_record.setImageResource(R.drawable.record_start);
iv_record.setEnabled(true);
isRecording = false;
Toast.makeText(this, result, Toast.LENGTH_SHORT).show();
});
});
}
// 处理录像动作
private void dealRecord() {
if (!isRecording) {
iv_record.setImageResource(R.drawable.record_stop);
cxv_preview.startRecord(15); // 开始录像
chr_cost.setVisibility(View.VISIBLE);
chr_cost.setBase(SystemClock.elapsedRealtime()); // 设置计时器的基准时间
chr_cost.start(); // 开始计时
isRecording = !isRecording;
} else {
iv_record.setEnabled(false);
cxv_preview.stopRecord(); // 停止录像
}
}
运行测试App,打开录像界面的初始效果如下图左图,此时除了预览画面外,界面下方还展示录制按钮。点击录制按钮录像,正在录像的界面如下右图所示,此时录制按钮换成了暂停按钮,其上方也跳动着已录制时长的数字。
新型播放器ExoPlayer
尽管录制视频的相机工具从经典相机Camera演进到了二代相机Camera2再到增强相机CameraX,然而播放视频仍是老控件MediaPlayer以及封装了MediaPlayer的视频视图,这个MediaPlayer用于播放本地的小视频还可以,如果用它播放网络视频就存在下列问题了:
- MediaPlayer不支持一边下载一边播放,必须等视频全部下载完才开始播放。
- MediaPlayer不支持视频直播协议,包括MPEG标准的自适应流(Dynamic Adaptive Streaming over HTTP, DASH)、苹果公司的直播流(HTTP Live Streaming, HLS)、微软公司的平滑流(Smooth Streaming)等。
- 未加密的视频容易被盗版,如果加密了,MediaPlayer反而无法播放加密视频。
为此Android在新一代的Jetppack库中推出了新型播放ExoPlayer,它的音视频内核依赖于原生的MediaCodec接口,不但能够播放MediaPlayer所支持的任意格式的视频,而且具备以下几点优异特性:
- 对于网络视频,允许一边下载一边播放。
- 支持三大视频直播协议,包括自适应流(DASH)、直播流(HLS)、平滑流(Smooth Streaming)。
- 只支持播放采取Widevine技术加密的网络视频。
- 只要提供了对应的字幕文件(srt格式),就支持在播放视频时同步显示字幕。
- 支持合并、串联、循环等多种播放方式。
Exoplayer居然能够做这么多事情,简直比MediaPlayer省心多了。当然,因为Exoplayer来自Jetpack库,所以使用之前要先修改build.gradle.kts,添加下面一行依赖配置:
implementation("com.google.android.exoplayer:exoplayer:2.19.1")
Exoplayer的播放界面采用播放器视图StylePlayerView,它的自定义属性分别说明如下:
- show_buffering:缓冲进度的显示模式,值为never时表示从不显示,值为when_playing时表示在播放时显示,值为always时表示一直显示。
- show_timeout:控制栏的消失间隔,单位为毫秒。
- use_controller:是否显示控制栏,值为true时表示显示控制栏,值为false时表示不显示控制栏。
- resize_mode:缩放模式。值为fit表示保持宽高比例缩放,值为fill表示填满播放器界面。
下面是布局文件中添加PlayerView节点的配置:
<com.google.android.exoplayer2.ui.StyledPlayerView
android:id="@+id/pv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:show_buffering="always"
app:show_timeout="5000"
app:use_controller="true"
app:resize_mode="fit"/>
回到活动页面的代码,再调用播放器视图的setPlayer方法,设置已经创建好的播放器对象,然后才能让播放器进行播空操作。设置播放器的代码模板如下:
// 创建一个新型播放器对象
private ExoPlayer mPlayer = new ExoPlayer.Builder(this).build();
StyledPlayerView pv_content = findViewById(R.id.pv_content);
pv_content.setPlayer(mPlayer); // 设置播放器视图的播放器对象
以上代码把StyledPlayerView与ExoPlayer关联起来,后续的视频播放过程分成以下几个步骤:
- 创建指定视频格式的工厂对象。
- 创建指定URI地址的媒体对象MediaItem。
- 基于格式工厂和媒体对象创建媒体来源MediaSource。
- 设置播放器对象的媒体来源以及其他的播控操作。
其中步骤4的操作与ExoPlayer有关,它的常见方法分别说明如下:
-
setMediaSource:设置播放器的媒体来源。
-
addListener:给播放添加时间事件监听器。需要重写监听器接口Player.Listener的onPlaybackStateChanged方法,根据状态参数判断事件类型(取值见下表)。
|Player类的播放状态| 说明 |
|–|–|
| STATE_BUFFERING | 视频正在缓冲 |
| STATE_READY | 视频准备就绪 |
| STATE_ENDED | 视频播放完毕 | -
prepare:播放器准备就绪。
-
play:播放器开始播放。
-
seekTo:拖动当前进度到指定位置。
-
isPlaying:判断播放器是否正在播放。
-
getCurrentPosition:获得播放器当前的播放位置。
-
pause:播放器暂停播放。
-
stop:播放器停止播放。
-
release:释放播放器资源。
接下来把网络视频与本地视频的播放代码整合到一起,从工厂构建到开始播放的示例代码如下:
private ExoPlayer mPlayer; // 声明一个新型播放器对象
// 播放视频
private void playVideo(Uri uri) {
DataSource.Factory factory = new DefaultDataSource.Factory(this);
// 创建指定地址的媒体对象
MediaItem videoItem = new MediaItem.Builder().setUri(uri).build();
// 基于工厂对象和媒体对象创建媒体来源
MediaSource videoSource = new ProgressiveMediaSource.Factory(factory)
.createMediaSource(videoItem);
mPlayer.setMediaSource(videoSource); // 设置播放器的媒体来源
// 给播放器添加事件监听器
mPlayer.addListener(new Player.Listener() {
@Override
public void onPlaybackStateChanged(int state) {
if (state == Player.STATE_BUFFERING) { // 视频正在缓冲
Log.d(TAG, "视频正在缓冲");
} else if (state == Player.STATE_READY) { // 视频准备就绪
Log.d(TAG, "视频准备就绪");
} else if (state == Player.STATE_ENDED) { // 视频播放完毕
Log.d(TAG, "视频播放完毕");
}
}
});
mPlayer.prepare(); // 播放器准备就绪
mPlayer.play(); // 播放器开始播放
}
再举个播放带字幕的视频例子,此时除了构建视频文件的媒体来源,还需要构建字幕文件的媒体来源(字幕文件为srt格式),然后合并视频的媒体来源与字幕来源得到最终的媒体来源。包含字幕处理的播放器代码如下:
// 播放带字幕的视频
private void playVideoWithSubtitle(Uri videoUri, Uri subtitleUri) {
Log.d(TAG, "getLanguage="+Locale.getDefault().getLanguage());
// 创建HTTP在线视频的工厂对象
DataSource.Factory factory = new DefaultDataSource.Factory(this);
// 创建指定地址的媒体对象
MediaItem videoItem = new MediaItem.Builder().setUri(videoUri).build();
// 基于工厂对象和媒体对象创建媒体来源
MediaSource videoSource = new ProgressiveMediaSource.Factory(factory)
.createMediaSource(videoItem);
// 语言要填null,否则中文会乱码。selectionFlags要填Format.NO_VALUE,否则看不到字幕
// 创建指定地址的字幕对象。ExoPlayer只支持srt字幕,不支持ass字幕
MediaItem.Subtitle subtitleItem = new MediaItem.Subtitle(subtitleUri,
MimeTypes.APPLICATION_SUBRIP, null, Format.NO_VALUE);
// 基于工厂对象和字幕对象创建字幕来源
MediaSource subtitleSource = new SingleSampleMediaSource.Factory(factory)
.createMediaSource(subtitleItem, C.TIME_UNSET);
// 合并媒体来源与字幕来源
MergingMediaSource mergingSource = new MergingMediaSource(videoSource, subtitleSource);
mPlayer.setMediaSource(mergingSource); // 设置播放器的媒体来源
mPlayer.prepare(); // 播放器准备就绪
mPlayer.play(); // 播放器开始播放
}
运行测试该App,可观察到ExoPlayer的播放效果如下图所示。其中,左图为网络视频的播放界面,右图为带字幕视频的播放界面。
工程源码
文章涉及所有代码可点击工程源码下载。