Android 實現視屏播放器、邊播邊快取功能、外加鏟屎(IJKPlayer)

戀貓de小郭發表於2016-11-18

Android 實現視屏播放器與邊播邊快取功能外加蹲坑鏟屎(IJKPlayer)

 hello,大家好,我就是那個會掀桌子的話嘮,剛剛結束兩篇關於音訊播放與錄製的文章,舊坑未埋就挖新坑,還望多多關照。最近累趴了,週末果斷休假。

快看,用力戳它:github.com/CarGuo/GSYV… 。專案是翻改至JieCaoVideoPlayer,本文特長,看官請耐心,妹子會有的。

效果

Android 實現視屏播放器、邊播邊快取功能、外加鏟屎(IJKPlayer)

開源播放器選擇

 Android上最為人熟知的MediaPlayer,對,就是這貨,在上兩篇音訊文章中頻頻露臉的傢伙,這次又有它的身影,然而還是這次不講他,就連他的封裝類VideoView也不講,呸呸呸,又扯了一堆沒用的。( ̄︶ ̄)>

  • ijkplayer,這次要推薦的是它,鼎鼎大名的BILIBILI開源的播放器。基於FFMPEG,支援Android與IOS,還封裝了谷歌親兒子MediaPlayer與乾兒子EXOPlayer(為什麼要用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事件,根據ViewgetId判斷觸控的是進度條還是介面,如果是介面判斷是左右滑動就顯示DialogseekTo,如果是上下就根據螢幕的左邊還是右邊來選擇是調節音量還是亮度
···
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。


效果GIF(比較大):

Android 實現視屏播放器、邊播邊快取功能、外加鏟屎(IJKPlayer)

1)、Window層級的

 
 傳聞每一個Activity都有一個com.android.internal.R.id.content,它默默的包含了各種你塞進去的物體,而且是一個FrameLayout,谷歌有太多它的傳說了,我們用它是就是。

 既然是FrameLayout,那麼我們往他裡面塞東西就好了,這裡我們可以在GSYVideoPlayer裡面寫一個方法,在點選全屏按鈕的時候:

  • 隱藏狀態列,清除當前TextureView。
  • 然後新建立一個GSYVideoPlayer2,只有把這個G2新增到window下FrameLayout
  • 設定它的播放狀態和當前列表這個邏輯播放器一致。
  • 最後把G2告知Manager承接畫面,這樣是就實現了無縫的列表到全屏啦,返回只需要倒著做就好了。

 
 在切換的時候可以做一些位移動畫,讓播放器的全屏更加友好,下面長程式碼來襲((/- -)/。深夜碼字不易,不知道為什麼每次這個時候老婆的意見很大啊。

Constructor constructor = (Constructor) 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)="" (!(misland="=" 1)="" &&="" !mclickport)="" return;="" }="" else="" mclickland="true;" mclick="false;" misland="1;" 1))="" screentype="ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;" activity.setrequestedorientation(activityinfo.screen_orientation_landscape);="" gsyvideoplayer.getfullscreenbutton().setimageresource(r.drawable.video_shrink);="" 設定反向橫屏="" (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大法好:AndroidVideoCache

 接入簡單,使用簡單,你可以趾高氣揚的和產品說,這個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了 #2104,不過目前還未解決,就是某些短小的視訊會無法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級:github.com/CarGuo/GSYV…


能看到這裡都是真愛啊,我最後問兩句,你們會覺得文章太長閱讀起來比較費勁嗎?

友情連結:

相關文章