安卓多媒体(音频录播、传统摄制、增强摄制)

本章介绍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视频.mp4MPEG4格式
THREE_GPP视频.3gp3GP格式
  • 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步:

  1. 由视频对象调用setVideoURI方法指定视频文件。
  2. 创建一个媒体控制条,并由视频视图对象调用setMediaController方法关联该控制条。
  3. 由控制条对象调用setMediaPlayer方法,将媒体播放器设置为该视频视图。
  4. 调用视频视图对象的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拍照之前先要初始化相机,包括界面预览以及参数设定等,具体的初始化步骤说明如下:

  1. 准备一个预览视图对象PreviewView,并添加至当前界面。
  2. 获取相机提供器对象ProcessCameraProvider。
  3. 构建预览对象Preview,指定预览的宽高比例。
  4. 构建摄像头选择器对象CameraSelector,指定使用前置摄像头还是后置摄像头。
  5. 构建图像捕捉器对象ImageCapture,分别设置捕捉模式、旋转角度、宽高比例、闪光模式等拍照参数。
  6. 调用相机提供器对象的bindToLifecyccle方法,把相机选择器、预览视图、图像捕捉绑定到相机提供器。
  7. 调用预览视图对象的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的处理。需要修改的代码主要有三个地方,分别说明如下:

  1. 第一个地方是在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" />
  1. 第二个地方是在重置相机的resetCamera方法中,构建完图像捕捉器对象后,还要构建视频捕捉器对象,并设置视频的宽高比例、视频帧率、比特率(视频每秒录制的比特数)、旋转角度等录制参数。视频捕捉器的构建代码示例如下:
if (mCameraMode == MODE_RECORD) { // 录像
    // 构建一个视频捕捉器
    mVideoCapture = new VideoCapture.Builder()
            .setTargetAspectRatio(mAspectRatio) // 设置宽高比例
            .setVideoFrameRate(60) // 设置视频帧率
            .setBitRate(3 * 1024 * 1024) // 设置比特率
            .setTargetRotation(rotation) // 设置旋转角度
            .setAudioRecordSource(MediaRecorder.AudioSource.MIC)
            .build();
}
  1. 第三个地方是在绑定摄像头的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用于播放本地的小视频还可以,如果用它播放网络视频就存在下列问题了:

  1. MediaPlayer不支持一边下载一边播放,必须等视频全部下载完才开始播放。
  2. MediaPlayer不支持视频直播协议,包括MPEG标准的自适应流(Dynamic Adaptive Streaming over HTTP, DASH)、苹果公司的直播流(HTTP Live Streaming, HLS)、微软公司的平滑流(Smooth Streaming)等。
  3. 未加密的视频容易被盗版,如果加密了,MediaPlayer反而无法播放加密视频。

为此Android在新一代的Jetppack库中推出了新型播放ExoPlayer,它的音视频内核依赖于原生的MediaCodec接口,不但能够播放MediaPlayer所支持的任意格式的视频,而且具备以下几点优异特性:

  1. 对于网络视频,允许一边下载一边播放。
  2. 支持三大视频直播协议,包括自适应流(DASH)、直播流(HLS)、平滑流(Smooth Streaming)。
  3. 只支持播放采取Widevine技术加密的网络视频。
  4. 只要提供了对应的字幕文件(srt格式),就支持在播放视频时同步显示字幕。
  5. 支持合并、串联、循环等多种播放方式。

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关联起来,后续的视频播放过程分成以下几个步骤:

  1. 创建指定视频格式的工厂对象。
  2. 创建指定URI地址的媒体对象MediaItem。
  3. 基于格式工厂和媒体对象创建媒体来源MediaSource。
  4. 设置播放器对象的媒体来源以及其他的播控操作。

其中步骤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的播放效果如下图所示。其中,左图为网络视频的播放界面,右图为带字幕视频的播放界面。
在这里插入图片描述

工程源码

文章涉及所有代码可点击工程源码下载。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/719040.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

SpringBoot3整合SpringDoc实现在线接口文档

写在前面 在现目前项目开发中&#xff0c;一般都是前后端分离项目。前端小姐姐负责开发前端&#xff0c;苦逼的我们负责后端开发 事实是一个人全干&#xff0c;在这过程中编写接口文档就显得尤为重要了。然而作为一个程序员&#xff0c;最怕的莫过于自己写文档和别人不写文档…

c函数/2024/6/17

1.递归计算0--n的和 #include <stdio.h> int sum(int n);//递归求和函数 int main(int argc, const char *argv[]) {//(2)递归计算0--n的和int n0;printf("请输入n的值为:");scanf("%d",&n);printf("0--n的和为:%d",sum(n));return 0…

AI早班车

全球AI新闻速递 1.国内团队制作AI短片《凤鸣山海》亮相北京电影节 国内团队制作AI短片《凤鸣山海》亮相北京电影节“光影未来”电影科技单元。独特的中国玄幻题材&#xff0c;朱雀、玄武、白虎、青龙&#xff0c;四大神兽栩栩如生 2.字节跳动拒绝出售TikTok&#xff0c;如果败…

【数据结构初阶】--- 堆的应用:topk

堆的功能&#xff1a;topk 为什么使用topk 先举个例子&#xff0c;假如说全国有十万家奶茶店&#xff0c;我现在想找到评分前十的店铺&#xff0c;现在应该怎么实现&#xff1f; 第一想法当然是排序&#xff0c;由大到小排序好&#xff0c;前十就能拿到了。这是一种方法&…

三星(中国)投资公司线上入职测评笔试邀请数字推理语言逻辑真题题库

三星&#xff08;中国&#xff09;有限公司北京分公司 邀请您参加 SHL线上笔试 具体安排如下&#xff1a; 笔试时间&#xff1a;周三 9:00 笔试时长&#xff1a;1.5h ~ 2h 笔试内容及要求&#xff1a;数字推理限时30min&#xff1b;语言逻辑限时30min&#xff1b;性格测试不…

【机器学习】第5章 朴素贝叶斯分类器

一、概念 1.贝叶斯定理&#xff1a; &#xff08;1&#xff09;就是“某个特征”属于“某种东西”的概率&#xff0c;公式就是最下面那个公式。 2.朴素贝叶斯算法概述 &#xff08;1&#xff09;是为数不多的基于概率论的分类算法&#xff0c;即通过考虑特征概率来预测分类。 …

你对SSH协议了解吗

SSH&#xff08;Secure Shell&#xff09;协议&#xff0c;作为网络通信领域的一项核心技术&#xff0c;以其卓越的安全性能和广泛的应用范围&#xff0c;成为保障网络通信安全的重要工具。本文将深入剖析SSH协议的工作原理、核心特性以及在现代网络通信中的关键作用&#xff0…

HTML静态网页成品作业(HTML+CSS)——新媒体专业介绍介绍网页(1个页面)

&#x1f389;不定期分享源码&#xff0c;关注不丢失哦 文章目录 一、作品介绍二、作品演示三、代码目录四、网站代码HTML部分代码 五、源码获取 一、作品介绍 &#x1f3f7;️本套采用HTMLCSS&#xff0c;未使用Javacsript代码&#xff0c;共有1个页面。 二、作品演示 三、代…

经历的分享

我是三本计算机科学技术跨考上岸的学生&#xff0c;本科阶段技术能力并没有掌握多少&#xff0c;在选择导师时屡屡碰壁&#xff0c;我当时向许多计算机方向的导师&#xff0c;比如大数据方向,计算机视觉 迁移学习和图像处理方向的导师全都拒绝了我&#xff0c;最终学校给我分配…

SpringCloudStream原理和深入使用

简单概述 Spring Cloud Stream是一个用于构建与共享消息传递系统连接的高度可扩展的事件驱动型微服务的框架。 应用程序通过inputs或outputs来与Spring Cloud Stream中binder对象交互&#xff0c;binder对象负责与消息中间件交互。也就是说&#xff1a;Spring Cloud Stream能…

Sunny v1.3.0 官方版 (简洁且漂亮截图应用)

前言 Sunny是一款漂亮又实用的“截图&钉图”的软件&#xff0c;亦支持“屏幕识图”和“OCR”的软件。 一、下载地址 下载链接&#xff1a;http://dygod/source 点击搜索&#xff1a;Sunny 二、安装步骤 1、解压后将Sunny.exe发送到桌面快捷方式 2、启动桌面图标 3、正…

下载lombok.jar包,简化类的代码

Download (projectlombok.org) 去这个网站下载lombok.jar包 打开这个包文件的位置,拖到项目lib文件夹: 在这里右键添加为库(Add as library)。 添加这三个注解即可&#xff0c;类里面不需要其他东西了

手写操作系统

对喜欢操作系统的伙伴强推一门课程 从0开始实现了支持文件系统、任务切换和网络协议栈的操作系统。 具体见 &#xff1a;http://www.ziyuanwang.online/977.html

012.指纹浏览器编译-修改canvas指纹(高级)

指纹浏览器编译-修改canvas指纹(高级) 一、canvas指纹是什么 之前介绍过canvas指纹和常见网站绕过canvas指纹&#xff0c;插眼&#xff1a; https://blog.csdn.net/w1101662433/article/details/137959179 二、为啥有更高级的canvas指纹 众所周知&#xff0c;creepjs和brow…

Java Lambda表达式:简洁代码的艺术与实战技巧

引言 Java Lambda表达式是Java SE8中引入的一项重要的语言特性&#xff0c;它允许我们以简洁的方式去编写代码&#xff0c;同时也能大大提高代码的可读性和编写的灵活性。结合Java8及以后版本中引入的Stream API&#xff0c;Lambda表达式使得集合操作变得更为直观和强大。本文将…

Codeforces Round 953 (Div. 2 ABCDEF题) 视频讲解

A. Alice and Books Problem Statement Alice has n n n books. The 1 1 1-st book contains a 1 a_1 a1​ pages, the 2 2 2-nd book contains a 2 a_2 a2​ pages, … \ldots …, the n n n-th book contains a n a_n an​ pages. Alice does the following: She …

Centos7如何扩容未做lvm的GPT硬盘

背景&#xff1a;一台根分区为2.5T(已转换GPT格式)的虚拟机使用率达到97%&#xff0c;需要扩容&#xff0c;但是又没做lvm 通过平台新增容量1.5T&#xff0c;如下可看到 安装growpart准备扩容&#xff1a; yum install cloud-utils-growpart -y 执行命令growpart报错&#xff…

11 数制介绍及转换

数制介绍 一、数制介绍 &#xff08;一&#xff09;计算机的数制 ​ 二进制这个词的意思是基于两个数字 ​ 二进制数或二进制位表示为0 和1 ​ 示例&#xff1a;10001011 ​ 十进制数制系统包括10 个数字&#xff1a;十进制数0、1、2、3、4、5、6、7、8、9 ​ 示例&…

性能测试项目实战

项目介绍和部署 项目背景 轻商城项目是一个现在流行的电商项目。我们需要综合评估该项目中各个关键接口的性能&#xff0c;并给出优化建议&#xff0c;以满足项目上线后的性能需要。 项目功能架构 前台商城&#xff1a;购物车、订单、支付、优惠券等 后台管理系统&#xf…

【挑战100天首通《谷粒商城》】-【第一天】06、环境-使用vagrant快速创建linux虚拟机

文章目录 课程介绍1、安装 linux 虚拟机2、安装 VirtualBoxStage 1&#xff1a;开启CPU虚拟化Stage 2&#xff1a;下载 VirtualBoxStage 2&#xff1a;安装 VirtualBoxStage 4&#xff1a;安装 VagrantStage 4-1&#xff1a;Vagrant 下载Stage 4-2&#xff1a;Vagrant 安装Stag…