Android 實現視屏播放器與邊播邊緩存功能外加蹲坑鏟屎(IJKPlayer)
hello,大家好,我就是那個會掀桌子的話嘮,剛剛結束兩篇關于音頻播放與錄制的文章,舊坑未埋就挖新坑,還望多多關照。最近累趴了,周末果斷休假。
</p>
快看,用力戳它:https://github.com/CarGuo/GSYVideoPlayer 。項目是翻改至<a >JieCaoVideoPlayer</a>,本文特長,看官請耐心,妹子會有的。
效果
</p>
開源播放器選擇
</p>
Android上最為人熟知的MediaPlayer,對,就是這貨,在上兩篇音頻文章中頻頻露臉的家伙,這次又有它的身影,然而還是這次不講他,就連他的封裝類VideoView也不講<( ̄︶ ̄)>,呸呸呸,又扯了一堆沒用的。
- <a >ijkplayer</a>,這次要推薦的是它,鼎鼎大名的BILIBILI開源的播放器。基于FFMPEG,支持Android與IOS,還封裝了谷歌親兒子MediaPlayer與干兒子<a >EXOPlayer</a>(為什么要用EXO),支持直播流,Star-9000多與fork-3000的視頻播放器你支持安利。(issues 600多算活躍嗎┑( ̄Д  ̄)┍)
集成工作還是有定的工作量的,它的DEMO肯定滿足不了欲求不滿的設計獅和產品汪的,這里我們不跑分,不打廣告,不講原理,只求站在巨人的肩膀上學(cao)習(xi),快速集成。
- 定義一個單例的視頻內核播放管理器。
- 自定義一個滿足你上下其手的TextureView
- 定義一個UI層級邏輯播放器
- 重力旋轉的相關邏輯處理
- 列表邏輯的相關處理
- 列表到全屏相關的邏輯處理
- 視頻緩存邏輯
1、播放管理器:GSYVideoManager
單例,沒得商量,它需要負責真正的播放請求與顯示邏輯,集成了IjkMediaPlayer,BILIBLI的開源小組還是很有心的,它的封裝和接口使用基本和MediaPlayer沒有什么區別,只需要用起來就好了。‘
這里我們要實現IjkMediaPlayer的播放接口,監聽IjkMediaPlayer的相關狀態回調然后封發到各個邏輯播放器中。從下方代碼可以看到,真的和MediaPlayer好像。
mediaPlayer = new IjkMediaPlayer();
//音頻類型
mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
//數據源
mediaPlayer.setDataSource(((GSYModel) msg.obj).getUrl(), ((GSYModel) msg.obj).getMapHeadData());
//播放完成
mediaPlayer.setOnCompletionListener(GSYVideoManager.this);
//緩沖
mediaPlayer.setOnBufferingUpdateListener(GSYVideoManager.this);
//常亮
mediaPlayer.setScreenOnWhilePlaying(true);
//加載完畢
mediaPlayer.setOnPreparedListener(GSYVideoManager.this);
//拖動
mediaPlayer.setOnSeekCompleteListener(GSYVideoManager.this);
//失敗
mediaPlayer.setOnErrorListener(GSYVideoManager.this);
//視頻相關信息-重要
mediaPlayer.setOnInfoListener(GSYVideoManager.this);
//視頻大小
mediaPlayer.setOnVideoSizeChangedListener(GSYVideoManager.this);】
//開始加載
mediaPlayer.prepareAsync();
監聽的回調接口里,大部分大家都耳目能詳吧,沒聽過也沒關系,都寫上就對了,但是最主要需要關注的兩個,一個是通過setOnVideoSizeChangedListener拿到視頻寬和高,這是我們后續正常顯示視頻的依靠之一。
另外一個就是setOnInfoListener,這里我們主要是獲取到視頻相關的元信息里視頻旋轉角度!還記得那時候對視頻播放不熟悉,和產品還有QA力爭“這個視頻本來就是轉了90度的,我就不改,你咬我嗎···”這樣的黑歷史。Σ( ° °|||)
特別是Android拍攝的豎屏視頻,旋轉不是視頻本身的圖像,而是增加了旋轉信息,而這個時候你需要做的就是識別它,然后轉了它丫的。另外,因為Android本身的MediaPlaer和VideoView自身就處理好所以不需要你旋轉。((ノO益O)ノ彡┻━┻親生的啊)
這里的接口主要是把當前播放的視頻狀態和信息到返回到邏輯播放器中。
@Override
public void onInfo(int what, int extra) {
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
BACKUP_PLAYING_BUFFERING_STATE = mCurrentState;
setStateAndUi(CURRENT_STATE_PLAYING_BUFFERING_START);
} else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
if (BACKUP_PLAYING_BUFFERING_STATE != -1) {
setStateAndUi(BACKUP_PLAYING_BUFFERING_STATE);
BACKUP_PLAYING_BUFFERING_STATE = -1;
}
} else if (what == IMediaPlayer.MEDIA_INFO_VIDEO_ROTATION_CHANGED) {
//這里返回了視頻旋轉的角度,根據角度旋轉視頻到正確的畫面
mRotate = extra;
if (mTextureView != null)
mTextureView.setRotation(mRotate);
}
}
2、自定義TextureView:GSYTextureView
為什么不用SurfaceView?因為TextureView很可愛啊。這里我們主要針對視頻的大小和旋轉角度設置TextureView的大小,詳細就不多說了(不是懶),挑其中一類講講,因為主要也是這個。
- 例如根據視頻的長寬比和屏幕的長寬比判斷,如果視頻寬與屏幕寬之比小于高之比,那么就需要按理比壓縮寬度,然后高度適應屏幕。
- 例如根據旋轉信息,判斷TextureView界面的比例是橫的還是豎的,如果View是豎的,而視頻也是豎的,那么因為旋轉了90度,那么讓視頻的高顯示為屏幕的寬度,從新計算旋轉后的寬度。
覺得看起來有點繞口?沒關系,用著用著就習慣了····
width = widthSpecSize;
height = heightSpecSize;
···
if (videoWidth * height < width * videoHeight) {
width = height * videoWidth / videoHeight;
} else if (videoWidth * height > width * videoHeight) {
height = width * videoHeight / videoWidth;
}
···
if (getRotation() != 0 && getRotation() % 90 == 0) {
if (widthS < heightS) {
if (width > height) {
width = (int) (width * (float) widthS / height);
height = widthS;
} else {
height = (int) (height * (float) width / widthS);
width = widthS;
}
} else {
if (width > height) {
height = (int) (height * (float) width / widthS);
width = widthS;
} else {
width = (int) (width * (float) widthS / height);
height = widthS;
}
}
}
3、UI層級邏輯播放器 GSYVideoPlayer
所有的UI邏輯基本都可以寫到這里,目前繼承了 FrameLayout,View.OnClickListener, View.OnTouchListener, SeekBar.OnSeekBarChangeListener, TextureView.SurfaceTextureListener和GSYMediaPlayerListener。
邏輯播放器實現的內容太多了,這里主要說幾個地方,好吧,我承認我懶╮(╯_╰)╭ ,但是寫太多了也沒人看啊,所以這里主要是說一些關鍵的點,有需要留言再開個坑聊一聊,反正有DEMO。
- 記錄界面的播放狀態,把播放管理器GSYVideoManager的狀態記錄下來,如果有別的邏輯播放器點擊播放了,就把原本的邏輯播放器狀態清空,所有邏輯播放器的整個界面的UI都是根據這個State來決定的。
在邏輯播放器中統一分發各種狀態,把被播放的manager狀態同步到這里,之后你想要在哪個邏輯播放器里播放只需要對應的設置狀態后把manager的監聽同步過來。
switch (mCurrentState) {
//正常初始化狀態
case CURRENT_STATE_NORMAL:
if (isCurrentMediaListener()) {
cancelProgressTimer();
GSYVideoManager.instance().releaseMediaPlayer();
}
break;
//loading中
case CURRENT_STATE_PREPAREING:
resetProgressAndTime();
break;
//播放中
case CURRENT_STATE_PLAYING:
startProgressTimer();
break;
//暫停
case CURRENT_STATE_PAUSE:
startProgressTimer();
break;
//錯誤-需要判斷是否切換了邏輯播放器
case CURRENT_STATE_ERROR:
if (isCurrentMediaListener()) {
GSYVideoManager.instance().releaseMediaPlayer();
}
break;
//結束
case CURRENT_STATE_AUTO_COMPLETE:
cancelProgressTimer();
mProgressBar.setProgress(100);
mCurrentTimeTextView.setText(mTotalTimeTextView.getText());
break;
}
- 增加界面的onTouch事件,根據View的getId判斷觸摸的是進度條還是界面,如果是界面判斷是左右滑動就顯示Dialog并seekTo,如果是上下就根據屏幕的左邊還是右邊來選擇是調節音量還是亮度。
···
case MotionEvent.ACTION_MOVE:
float deltaX = x - mDownX;
float deltaY = y - mDownY;
float absDeltaX = Math.abs(deltaX);
float absDeltaY = Math.abs(deltaY);
//是全屏還是設置了可以觸摸
if (mIfCurrentIsFullscreen || mIsTouchWiget) {
//之前是否已經符合了觸摸邏輯條件
if (!mChangePosition && !mChangeVolume && !mBrightness) {
//如果手指動了超過一定距離就可以判斷是滑動,防止點擊的誤判的
if (absDeltaX > mThreshold || absDeltaY > mThreshold) {
cancelProgressTimer();
//如果是左右的就是進度
if (absDeltaX >= mThreshold) {
mChangePosition = true;
mDownPosition = getCurrentPositionWhenPlaying();
if (mVideoAllCallBack != null && isCurrentMediaListener()) {
mVideoAllCallBack.onTouchScreenSeekPosition(mUrl, mObjects);
}
} else {
//如果是上下的判斷是左邊還是右邊
if (mFirstTouch) {
mBrightness = mDownX < mScreenWidth * 0.5f;
mFirstTouch = false;
}
if (!mBrightness) {
mChangeVolume = true;
mGestureDownVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
if (mVideoAllCallBack != null && isCurrentMediaListener()) {
mVideoAllCallBack.onTouchScreenSeekVolume(mUrl, mObjects);
}
}
}
}
}
}
···
//根據flag執行邏輯
- 要監聽TextureView.setSurfaceTextureListener來通知畫面的創建和銷毀,比如回到后臺,onPause等。
這里有一個是TextureView的動態添加,動態添加的好處是你可以在不停止視頻的情況下載不同的邏輯播放器中切換視頻播放,比如列表全屏。
protected void addTextureView() {
if (mTextureViewContainer.getChildCount() > 0) {
mTextureViewContainer.removeAllViews();
}
mTextureView = null;
mTextureView = new GSYTextureView(getContext());
mTextureView.setSurfaceTextureListener(this);
mTextureView.setRotation(mRotate);
RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
layoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
mTextureViewContainer.addView(mTextureView, layoutParams);
}
···
//把Surface丟給視頻播放管理
@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
mSurface = new Surface(surface);
GSYVideoManager.instance().setDisplay(mSurface);
}
//告訴視頻播放渲染畫面銷毀了
@Override
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
GSYVideoManager.instance().setDisplay(null);
surface.release();
return true;
}
- 每次播放都要把Manager的player的監聽移到當前播放的邏輯播放器,這樣才能夠正確的監聽視頻的播放狀態。
//這里其實就有播放管理器的監聽分發保存的邏輯需要注意
GSYVideoManager.instance().setLastListener(this);
GSYVideoManager.instance().setListener(gsyVideoPlayer);
3、列表全屏邏輯 :Window層級的全屏、單例邏輯播放器的全屏ListVideoUtil。
</p>
效果GIF(比較大):
1)、Window層級的
傳聞每一個Activity都有一個com.android.internal.R.id.content,它默默的包含了各種你塞進去的物體,而且是一個FrameLayout,谷歌有太多它的傳說了,我們用它是就是。
既然是FrameLayout,那么我們往他里面塞東西就好了,這里我們可以在GSYVideoPlayer里面寫一個方法,在點擊全屏按鈕的時候:
- 隱藏狀態欄,清除當前TextureView。
- 然后新創建一個GSYVideoPlayer2,只有把這個G2添加到window下FrameLayout。
- 設置它的播放狀態和當前列表這個邏輯播放器一致。
- 最后把G2告知Manager承接畫面,這樣是就實現了無縫的列表到全屏啦,返回只需要倒著做就好了。
在切換的時候可以做一些位移動畫,讓播放器的全屏更加友好,下面長代碼來襲((/- -)/。深夜碼字不易,不知道為什么每次這個時候老婆的意見很大啊。
Constructor<GSYBaseVideoPlayer> constructor = (Constructor<GSYBaseVideoPlayer>) GSYBaseVideoPlayer.this.getClass().getConstructor(Context.class);
final GSYBaseVideoPlayer gsyVideoPlayer = constructor.newInstance(getContext());
//記錄新創建的這個video的id,在返回的時候通過它銷毀
gsyVideoPlayer.setId(FULLSCREEN_ID);
WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
final int w = wm.getDefaultDisplay().getWidth();
final int h = wm.getDefaultDisplay().getHeight();
//設置黑色背景,自動充滿全屏
FrameLayout.LayoutParams lpParent = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
FrameLayout frameLayout = new FrameLayout(context);
frameLayout.setBackgroundColor(Color.BLACK);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
//如果5.0的話,先讓播放器出現的位置和列表中一直,再樣式一會執行到屏幕中間的過度動畫效果
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(getWidth(), getHeight());
lp.setMargins(mListItemRect[0], mListItemRect[1], 0, 0);
frameLayout.addView(gsyVideoPlayer, lp);
vp.addView(frameLayout, lpParent);
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
TransitionManager.beginDelayedTransition(vp);
resolveFullVideoShow(context, gsyVideoPlayer, h, w);
}
}, 300);
} else {
//5.0一下直接顯示
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(getWidth(), getHeight());
frameLayout.addView(gsyVideoPlayer, lp);
vp.addView(frameLayout, lpParent);
resolveFullVideoShow(context, gsyVideoPlayer, h, w);
}
//設置全屏邏輯播放器的狀態,動態及添加播放view
gsyVideoPlayer.setUp(mUrl, mCache, mObjects);
gsyVideoPlayer.setStateAndUi(mCurrentState);
gsyVideoPlayer.addTextureView();
//添加監聽
GSYVideoManager.instance().setLastListener(this);
GSYVideoManager.instance().setListener(gsyVideoPlayer);
2)、ListVideoUtil的單例模式
這里利用另外一種實現思路,列表的邏輯播放器只用一個,因為普通的list在滑動的時候會有復用和銷毀,這會導致視頻被釋放而停止了,如果你是和今日黃(tou)條一樣的視頻列表播放效果,滑出屏幕就停止那無所謂。
如果你需要無論怎么滑動,視頻都在原來的位置播放的話,那么ListVideoUtil適合你,,內部它已經帶了全屏,防錯位,旋轉的各種邏輯,直接上代碼,有興趣的看DEMO。
listVideoUtil = new ListVideoUtil(this);
//設置列表最外層的布局用于全屏,空FrameLayout
listVideoUtil.setFullViewContainer(videoFullContainer);
//全屏隱藏狀態欄,如果有的話
listVideoUtil.setHideStatusBar(true);
···
//在列表中吧列表位置,封面,哪個列表的TAG,列表視頻的承載ViewGroup,播放按鍵傳入到Utils中
listVideoUtil.addVideoPlayer(position, imageView, TAG, holder.videoContainer, holder.playerBtn);
holder.playerBtn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//每次播放都要更新列表讓其他的item恢復狀態
notifyDataSetChanged();
//設置播放的tag和位置,防止錯位
listVideoUtil.setPlayPositionAndTag(position, TAG);
//開始播放
final String url = "http://baobab.wdjcdn.com/14564977406580.mp4";
listVideoUtil.startPlay(url);
}
});
4、OrientationUtils 重力旋轉的工具類
OrientationUtils使用的是OrientationEventListener,通過手機的角度判斷需要旋轉到哪個位置。為什么用它?因為谷歌到的時候剛好看到,緣分啊懂嗎。
這里需要個關注的是手動點擊和自動旋轉之間的沖突,主要看代碼吧,老婆開始催我了 (ノ?益?)ノ彡┻━┻。
//判斷系統是否開了旋轉,是的,這貨不需要系統旋轉是否開啟
boolean autoRotateOn = (android.provider.Settings.System.getInt(activity.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 1);
if (!autoRotateOn) {
if (mIsLand == 0) {
return;
}
}
// 設置豎屏
if (((rotation >= 0) && (rotation <= 30)) || (rotation >= 330)) {
//是否點擊導致的
if (mClick) {
if (mIsLand > 0 && !mClickLand) {
return;
} else {
//清除狀態
mClickPort = true;
mClick = false;
mIsLand = 0;
}
} else {
//自動旋轉
if (mIsLand > 0) {
screenType = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
gsyVideoPlayer.getFullscreenButton().setImageResource(R.drawable.video_enlarge);
mIsLand = 0;
mClick = false;
}
}
}
// 設置橫屏
else if (((rotation >= 230) && (rotation <= 310))) {
if (mClick) {
if (!(mIsLand == 1) && !mClickPort) {
return;
} else {
mClickLand = true;
mClick = false;
mIsLand = 1;
}
} else {
if (!(mIsLand == 1)) {
screenType = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
gsyVideoPlayer.getFullscreenButton().setImageResource(R.drawable.video_shrink);
mIsLand = 1;
mClick = false;
}
}
}
// 設置反向橫屏
else if (rotation > 30 && rotation < 95) {
if (mClick) {
if (!(mIsLand == 2) && !mClickPort) {
return;
} else {
mClickLand = true;
mClick = false;
mIsLand = 2;
}
} else if (!(mIsLand == 2)) {
screenType = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
gsyVideoPlayer.getFullscreenButton().setImageResource(R.drawable.video_shrink);
mIsLand = 2;
mClick = false;
}
}
}
};
orientationEventListener.enable();
6、邊播邊緩存
好吧,老婆睡了,我偷偷起來了(。??)ノ
這個需求曾經讓我徹夜難眠,因為IJKPlayer不支持,好吧,沒見過哪個播放器支持的,和產品爭(tuo)論(yan)需(shi)求(jian)之后,最終還是github大法好:<a >AndroidVideoCache</a>。
接入簡單,使用簡單,你可以趾高氣揚的和產品說,這個so easy了。
HttpProxyCacheServer proxy = getProxy();
//注意不能傳入本地路徑,本地的你還傳進來干嘛。
String proxyUrl = proxy.getProxyUrl(VIDEO_URL);
videoView.setVideoPath(proxyUrl);
該項目的原理其實就是將url鏈接轉化為本地鏈接 h t t p://127.0.0.1:LocalPort/url,然后它開一個服務器一邊下載緩存視頻,一邊把緩存的數據正常返回給你的播放器,如果已經緩存過的這里會返回一個本地文件路徑。Σ( ° °|||)︴曾經的我真的是too young too smiple。
5、一些坑和說明
1、IJKPLAY的后臺播放和回到前臺恢復畫面的速度之快是其他播放器(我坐井觀天)無法比擬的,真的好快,而且適合你,因為你什么都不用做。
2、IJKPLAY有一個問題,我也提過ISSUSE了 <a >#2104</a>,不過目前還未解決,就是某些短小的視頻會無法seekTo,說是FFMEPG的問題,然后就太監了。
3、IJKPLAY庫里還封裝了exoplayer谷歌干兒子,用法也基本一致,這個播放器自己內部判斷旋轉,不會有上面的seekto問題,可是后臺或者onPause之后的畫面恢復速度堪憂啊,各位遇到過嗎?
4、千萬別開硬解碼,不然會這樣。 ( ‵o′)凸
5、拖動進度條,需要在停止拖動的時候,判斷視頻是不是已經播放完了被釋放了。
6、如果橫屏全屏的話,恢復到正常畫面是最好有一個延時,這樣畫面才不會出現背景抖動的問題,還有最關鍵的,Maifest文件。
//不要忘記配置activity,所有背景的activity
android:configChanges="orientation|keyboardHidden|screenSize"
- 7、普通列表中播放視頻在快速移動可能出現的錯位問題
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
int lastVisibleItem = firstVisibleItem + visibleItemCount;
//大于0說明有播放
if (GSYVideoManager.instance().getPlayPosition() >= 0) {
//當前播放的位置
int position = GSYVideoManager.instance().getPlayPosition();
//對應的播放列表TAG
if (GSYVideoManager.instance().getPlayTag().equals(ListNormalAdapter.TAG)
&& (position < firstVisibleItem || position > lastVisibleItem)) {
//如果滑出去了上面和下面就是否,和今日頭條一樣
GSYVideoPlayer.releaseAllVideos();
listNormalAdapter.notifyDataSetChanged();
}
}
}
到底了呢(o)/。
下面的的看到了嗎 ?<( ̄︶ ̄)>
點我點我上60級:https://github.com/CarGuo/GSYVideoPlayer
</p>
能看到這里都是真愛啊,我最后問兩句,你們會覺得文章太長閱讀起來比較費勁嗎?
相關文章: Android 列表視頻的全屏、自動小窗口優化實踐