项目需要做一个简单的播放视频功能demo,后期会换成公司自己的组件,所以就没考虑使用第三方库了,直接上系统的VideoView,在这里记录下操作; 顺便吐槽下:一直都听说简书编辑器好用,第一次使用,有点失望,markdown跟效果分栏竟然不能同步滚动,也不支持[TOC],没有个目录实在很不习惯,表情图标也不能插入,代码区块加空行经常都识别不了啊,就不能让我简单滴从笔记中直接粘贴md文本吗… ==!
Demo项目下载 自己封装了一个播放器
资源
Android三种播放视频的方式
Android播放器框架分析之AwesomePlayer
音频与视频播放 讲的player类,比较全
Android视频播放器实现小窗口和全屏状态切换
视频播放原理: 系统会首先确定视频的格式,然后得到视频的编码..然后对编码进行解码,得到一帧一帧的图像,最后在画布上进行迅速更新,显然需要在独立的线程中完成,这时就需要使用surfaceView了
android 支持的编码格式
基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 VideoView mVv = (VideoView) findViewById(R.id.vv); mVv.setMediaController(new MediaController(this )); Uri rawUri = Uri.parse("android.resource://" + getPackageName() + "/" + R.raw.shuai_dan_ge); mVv.setVideoURI(rawUri); mVideoUri = Uri.parse("http://****/abc.mp4" ); mVv.setVideoPath(mVideoUri.toString()); mVv.start(); mVv.requestFocus();
错误信息 QCMediaPlayer.java
1 2 3 4 5 6 7 //常见错误: "无法播放此视频" -我测试的是:红米1s电信版4.4.4无法播放,但在三星s6(5.1.1)上就可以播放 //播放源:http://27.152.191.198/c12.e.99.com/b/p/67/c4ff9f6535ac41a598bb05bf5b05b185/c4ff9f6535ac41a598bb05bf5b05b185.v.854.480.f4v MediaPlayer-JNI: QCMediaPlayer mediaplayer NOT present MediaPlayer: Unable to create media player MediaPlayer: Couldn't open file on client side, trying server side MediaPlayer: error (1, -2147483648) MediaPlayer: Error (1,-2147483648)
有人说 用下面的方式可以处理该异常,但我是使用系统封装好的控件,这个操作不到吧? 先记录下:
1 2 MediaPlayer player = MediaPlayer.create(this , Uri.parse(sound_file_path)); MediaPlayer player = MediaPlayer.create(this , soundRedId, loop);
全屏播放 - 横竖屏切换
androidmanifest.xml
中依然还是定义竖屏,并定义一个切换横纵屏按钮 btnChange
:
1 2 3 4 5 <activity android:name ="lynxz.org.video.VideoActivity" android:configChanges ="keyboard|orientation|screenSize" android:screenOrientation ="portrait" android:theme ="@style/Theme.AppCompat.Light.NoActionBar" />
布局:需要在 VidioView
外层套一个容器,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <RelativeLayout android:id ="@+id/rl_vv" android:layout_width ="match_parent" android:layout_height ="200dp" android:background ="@android:color/black" android:minHeight ="200dp" android:visibility ="visible" > <VideoView android:id ="@+id/vv" android:layout_width ="match_parent" android:layout_height ="wrap_content" android:layout_centerInParent ="true" /> </RelativeLayout >
这么做是为了在切换屏幕方向的时候对 rl_vv
进行拉伸,而内部的 VideoView
会依据视频尺寸重新计算宽高,我们看看其 onMeasure()
源码就明了了,但若是直接具体指定了view的宽高,则视频会被拉伸:
1 2 3 4 5 6 7 8 @Override protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { int width = getDefaultSize(mVideoWidth, widthMeasureSpec); int height = getDefaultSize(mVideoHeight, heightMeasureSpec); ...... setMeasuredDimension(width, height); }
按钮监听,手动切换
1 2 3 4 5 6 7 btnSwitch.setOnClickListener(View -> { if (getRequestedOrientation() == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } else { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); } });
设置VideoView布局尺寸
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Override public void onConfigurationChanged (Configuration newConfig) { super .onConfigurationChanged(newConfig); if (mVv == null ) { return ; } if (this .getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE){ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); getWindow().getDecorView().invalidate(); float height = DensityUtil.getWidthInPx(this ); float width = DensityUtil.getHeightInPx(this ); mRlVv.getLayoutParams().height = (int ) width; mRlVv.getLayoutParams().width = (int ) height; } else { final WindowManager.LayoutParams attrs = getWindow().getAttributes(); attrs.flags &= (~WindowManager.LayoutParams.FLAG_FULLSCREEN); getWindow().setAttributes(attrs); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); float width = DensityUtil.getWidthInPx(this ); float height = DensityUtil.dip2px(this , 200.f ); mRlVv.getLayoutParams().height = (int ) height; mRlVv.getLayoutParams().width = (int ) width; } }
自定义工具类
1 2 3 4 5 6 7 8 9 public static final float getHeightInPx (Context context) { final float height = context.getResources().getDisplayMetrics().heightPixels; return height; } public static final float getWidthInPx (Context context) { final float width = context.getResources().getDisplayMetrics().widthPixels; return width; }
另外,如果是将播放器放于fragment中进行横竖屏切换,则需要在onCreateView中setRetainInstance(true);
,这样旋转后,才不会重新创建从头开始播放;
获取第一帧的内容作为封面 参考文章
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private void createVideoThumbnail () { Observable<Bitmap> observable = Observable.create(new Observable.OnSubscribe<Bitmap>() { @Override public void call (Subscriber<? super Bitmap> subscriber) { Bitmap bitmap = null ; MediaMetadataRetriever retriever = new MediaMetadataRetriever(); int kind = MediaStore.Video.Thumbnails.MINI_KIND; if (Build.VERSION.SDK_INT >= 14 ) { retriever.setDataSource(mVideoUrl, new HashMap<String, String>()); } else { retriever.setDataSource(mVideoUrl); } bitmap = retriever.getFrameAtTime(); subscriber.onNext(bitmap); retriever.release(); } }); observable.observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io()) .subscribe(new Action1<Bitmap>() { @Override public void call (Bitmap bitmap) { mYourVideoPlayerContainer.setBackgroundDrawable(new BitmapDrawable(bitmap)); } }); }
滑动改变屏幕亮度/音量
滑动修改音量 修改上方的 onScroll()
方法,调用以下操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private void setVoiceVolume (boolean volumeUp) { if (volumeUp) { mAudioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FX_FOCUS_NAVIGATION_UP); } else { mAudioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FX_FOCUS_NAVIGATION_UP); } }
在页面关闭时可考虑恢复亮度/音量初始值
在onTouch的时候对触点进行判断,区分是修改音量或是改变亮度
需要处理的问题 拖动进度条,手动seekTo后,进度会跳动 断点跟踪后发现是native方法的问题,各大视频播放平台的客户端,比较普遍存在,暂无法处理:
找到些资源:
关于Android VideoView seekTo不准确的解决方案
视频关键帧提取 第一个提到的关键帧问题,我找了个视频测试了下,seekTo到固定的时间点,则跳变的位置也固定;
暂停/恢复 页面时,视频重新加载 现象: 在视频播放时,使页面 onPause()
,之后再恢复,则 videoView
会重新开始播放,临时的处理方案是在 onPause()
的时候记录当前播放进度位置,在 onResume()
的时候拖动到该进度位置,但是该方案仍会有黑屏现象,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 int mPlayingPos = 0 ;@Override protected void onPause () { mPlayingPos = mVideoView.getCurrentPosition(); mVideoView.stopPlayback(); super .onPause(); } @Override protected void onResume () { if (mPlayingPos > 0 ) { mVideoView.start(); mVideoView.seekTo(mPlayingPos); mPlayingPos = 0 ; } super .onResume(); }
找到些可能相关的文章,链接已失效,快照如下(还得去看看 surfaceView
啊 ~ ~# ): 另一篇类似的: android开发常见问题 问题7,也指明是 surfaceview
的原因,之所以是黑色的见后面的解释:
Activity 调用的顺序是 onPause() -> onStop() SurfaceView 调用了 surfaceDestroyed() 方法 然后再切回程序 Activity 调用的顺序是 onRestart() -> onStart() -> onResume () SurfaceView` 调用了 surfaceChanged() -> surfaceCreated() 方法 按挂断键或锁定屏幕 Activity 只调用 onPause() 方法 解锁后 Activity 调用 onResume() 方法 SurfaceView 什么方法都不调用
网络变化/切换应用后恢复播放 播放过程中,假如只缓冲了一部分视频,则当播放完缓冲部分后,会抛出1004异常,即使此时网络连接已经恢复,控件也不会自动继续缓冲:MediaPlayer: error (1, -1004)
源码注释: File or network related operation errors
同时,由于SurfaceView在页面onStop()时会destroy,比如播放时,用户按下home键或切换到其他应用页面再返回时,视频播放停止,此时需要重新加载视频并播放到上次停止的位置;
另外,有测试发现在三星G9200手机上,报 1004
这个错的时候会弹出错误提示框,然后卡死重启… 对比了下日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Override public void handleMessage (Message msg) { if (mMediaPlayer.mNativeContext == 0 ) { Log.w(TAG, "mediaplayer went away with unhandled events" ); return ; } switch (msg.what) { case MEDIA_ERROR: Log.e(TAG, "Error (" + msg.arg1 + "," + msg.arg2 + ")" ); boolean error_was_handled = false ; if (mOnErrorListener != null ) { error_was_handled = mOnErrorListener.onError(mMediaPlayer, msg.arg1, msg.arg2); } if (mOnCompletionListener != null && ! error_was_handled) { mOnCompletionListener.onCompletion(mMediaPlayer); } stayAwake(false ); return ; default : Log.e(TAG, "Unknown message type " + msg.what); return ; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public void setOnErrorListener (OnErrorListener l) { mOnErrorListener = l; } private MediaPlayer.OnErrorListener mErrorListener = new MediaPlayer.OnErrorListener() { public boolean onError (MediaPlayer mp, int framework_err, int impl_err) { ...... if (mOnErrorListener != null ) { if (mOnErrorListener.onError(mMediaPlayer, framework_err, impl_err)) { return true ; } } if (getWindowToken() != null ) { Resources r = mContext.getResources(); int messageId; if (framework_err == MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK) { messageId = com.android.internal.R.string.VideoView_error_text_invalid_progressive_playback; } else { messageId = com.android.internal.R.string.VideoView_error_text_unknown; } new AlertDialog.Builder(mContext) .setMessage(messageId) .setPositiveButton(com.android.internal.R.string.VideoView_error_button, new DialogInterface.OnClickListener() { public void onClick (DialogInterface dialog, int whichButton) { if (mOnCompletionListener != null ) { mOnCompletionListener.onCompletion(mMediaPlayer); } } }) .setCancelable(false ) .show(); } return true ; } };
因此需要对网络变化进行监听:
1 2 <uses-permission android:name ="android.permission.INTERNET" /> <uses-permission android:name ="android.permission.ACCESS_NETWORK_STATE" />
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 @Override protected void onResume () { super .onResume(); mNetworkState = NetworkHelper.getNetworkType(this ); if (SCHEME_HTTP.equalsIgnoreCase(mVideoUri.getScheme()) && mNetworkState == 0 ) { MessageUtils.showAlertDialog(this , "提示" , getResources().getString(R.string.network_error), null ); } else { if (mPlayingPos > 0 ) { mVv.start(); mVv.seekTo(mPlayingPos); mPlayingPos = 0 ; } } } private void registerNetworkReceiver () { if (mNetworkReceiver == null ) { mNetworkReceiver = new BroadcastReceiver() { @Override public void onReceive (Context context, Intent intent) { String action = intent.getAction(); if (SCHEME_HTTP.equalsIgnoreCase(mVideoUri.getScheme()) && action.equalsIgnoreCase(ConnectivityManager.CONNECTIVITY_ACTION)) { doWhenNetworkChange(); } } }; } registerReceiver(mNetworkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } public void doWhenNetworkChange () { mNetworkState = NetworkHelper.getNetworkType(this ); int bufferPercentage = mVv.getBufferPercentage(); mLastLoadLength = bufferPercentage * mVv.getDuration() / 100 ; int currentPosition = mVv.getCurrentPosition(); if (currentPosition > 0 ) { mPlayingPos = currentPosition; } debugLog(bufferPercentage + " 网络变化 ... " + mNetworkState + " 缓存长度 " + mLastLoadLength + " -- " + currentPosition); if (mNetworkState == NetworkHelper.NETWORK_TYPE_INVALID && bufferPercentage < 100 ) { if (mCheckPlayingProgressTimer == null ) { mCheckPlayingProgressTimer = new Timer(); } mCheckPlayingProgressTimer.schedule(new TimerTask() { @Override public void run () { if (mPlayingPos >= mLastLoadLength - deltaTime) { mVv.pause(); } } }, 0 , 1000 ); } else { restartPlayVideo(); } } private void restartPlayVideo () { if (mCheckPlayingProgressTimer != null ) { mCheckPlayingProgressTimer.cancel(); mCheckPlayingProgressTimer = null ; } mVv.setVideoURI(mVideoUri); mVv.start(); mVv.seekTo(mPlayingPos); mLastLoadLength = -1 ; mPlayingPos = 0 ; } @Override protected void onPause () { mPlayingPos = mVv.getCurrentPosition(); mVv.pause(); super .onPause(); } @Override protected void onStop () { mVv.stopPlayback(); mLastLoadLength = 0 ; debugLog("onResume " + mPlayingPos + " -- " + mLastLoadLength); super .onStop(); } @Override protected void onDestroy () { super .onDestroy(); if (mCheckPlayingProgressTimer != null ) { mCheckPlayingProgressTimer.cancel(); mCheckPlayingProgressTimer = null ; } ...... unregisterNetworkReceiver(); }
seekbar变化超出缓冲长度 使用系统提供的控件 mVv.setMediaController(new MediaController(this));
的话,在断网时,仍可以拖动超出缓冲长度的范围,会报错,这个还是得自定义才能控制可拖动位置,不再赘述;
1 2 MediaPlayer: Attempt to perform seekTo in wrong state: mPlayer=0x7f7ebbf5c0, mCurrentState=0 MediaPlayer: Error (1,-1004)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private final OnSeekBarChangeListener mSeekListener = new OnSeekBarChangeListener() { @Override public void onProgressChanged (SeekBar bar, int progress, boolean fromuser) { if (!fromuser) { return ; } long duration = mPlayer.getDuration(); long newposition = (duration * progress) / 1000L ; mPlayer.seekTo( (int ) newposition); if (mCurrentTime != null ) mCurrentTime.setText(stringForTime( (int ) newposition)); } }
当断网后,用户拖动超出缓冲区长度的话mediaplayer报错,此时再次点击VideoView区域,不会触发显示控制条,真是各种不方便啊,还是建议自己写一个控制条;
SurfaceView 资源
SurfaceView 源码分析及使用 这篇讲到了 SurfaceView
会显示黑色区域的原因:
SurfaceView 的 draw 和 dispatchDraw 方法中看到,SurfaceView 中,windownType变量被初始化为WindowManager.LayoutParams.TYPE_APPLICATION_MEDIA,所以在创建绘制这个 View 的过程中整个 Canvas 会被涂成黑色
浮层视频效果,在另外一个Window使用SurfaceView无法正常显示的问题排查与解决
surfaceView黑屏
无内容时,默认会绘制黑色背景图
1 2 3 4 5 6 7 8 9 10 11 12 int mWindowType = WindowManager.LayoutParams.TYPE_APPLICATION_MEDIA;public void draw (Canvas canvas) { if (mWindowType != WindowManager.LayoutParams.TYPE_APPLICATION_PANEL) { if ((mPrivateFlags & PFLAG_SKIP_DRAW) == 0 ) { canvas.drawColor(0 , PorterDuff.Mode.CLEAR); } } super .draw(canvas); }
感谢@尚弟很忙哒 的提醒, 设置页面主题为透明(android:theme="@android:style/Theme.Translucent"
)时,在初始缓冲阶段,VideoView区域会变成透明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android ="http://schemas.android.com/apk/res/android" xmlns:tools ="http://schemas.android.com/tools" android:layout_width ="match_parent" android:layout_height ="match_parent" tools:context ="org.lynxz.androiddemos.VideoViewActivity" > <RelativeLayout android:layout_width ="match_parent" android:layout_height ="200dp" android:background ="@android:color/holo_blue_bright" > <VideoView android:id ="@+id/vv" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:layout_centerInParent ="true" /> </RelativeLayout > </RelativeLayout >
在SurfaceView类开头有酱紫的一段注释,大概能解释为什么缓冲时候视频区域会透明了:
1 2 The surface is Z ordered so that it is behind the window holding its SurfaceView; the SurfaceView punches a hole in its window to allow its surface to be displayed.
因此处理方案就变成将SurfaceView挪到上层即可:
1 mVv.setZOrderOnTop(true );
不过挪动之后就可以设置VideoView的背景,此时才不会遮盖实际的视频绘图了,xml中指定吧,这里省略,不过如果VideoView区域还有其他控件的话,会被遮盖,所以最后我就没设定zorderOnTop了,而是直接在xml中指定VideoView的背景色,然后在onPrepare回调的时候,去掉背景即可(按需延时,或者在有播放进度,要更新进度条的时候进行去掉背景操作都ok,不然可能会有一瞬间的透明):
1 mVv.setBackgroundColor(Color.TRANSPARENT);
之前是打算像网上说的给VideoView的holder添加一个callback,(mVv.getHolder().addCallback(new SurfaceHolder.Callback() {...}
) ,在 surfaceCreated()
的时候获取canvas并手动绘制背景色,但是 holder.lockCanvas()
一直返回 null
,log信息提示:
1 2 3 4 E/SurfaceHolder: Exception locking surface java.lang.IllegalArgumentException at android.view.Surface.nativeLockCanvas(Native Method) .......
看到native我暂时就没招了,打住,老实用变通方法吧;
手机 “菜单键” 导致应用被stop,虽然此时看起来可见SurfaceView.java
的注释: 在调用菜单键的时候虽然页面貌似可见,但实际已经调用了onStop()方法了,而surface在window不可见时会销毁:
The Surface will be created for you while the SurfaceView’s window is visible; you should implement {@link SurfaceHolder.Callback#surfaceCreated} and {@link SurfaceHolder.Callback#surfaceDestroyed} to discover when the Surface is created and destroyed as the window is shown and hidden.
VideoView无法播放f4v格式(三星s6可以播放,红米1s(4.4.4)播放失败)…. 以后能力够了可以参考下这篇 :