用MediaPlayer+TextureView封裝一個完美實現全屏、小視窗的視訊播放器

weixin_34253539發表於2017-05-22

專案已新增IjkPlayer支援,後續逐漸完善其他功能。
地址:github.com/xiaoyanger0…

為什麼使用TextureView

在Android總播放視訊可以直接使用VideoViewVideoView是通過繼承自SurfaceView來實現的。SurfaceView的大概原理就是在現有View的位置上建立一個新的Window,內容的顯示和渲染都在新的Window中。這使得SurfaceView的繪製和重新整理可以在單獨的執行緒中進行,從而大大提高效率。但是呢,由於SurfaceView的內容沒有顯示在View中而是顯示在新建的Window中, 使得SurfaceView的顯示不受View的屬性控制,不能進行平移,縮放等變換,也不能放在其它RecyclerViewScrollView中,一些View中的特性也無法使用。

TextureView是在4.0(API level 14)引入的,與SurfaceView相比,它不會建立新的視窗來顯示內容。它是將內容流直接投放到View中,並且可以和其它普通View一樣進行移動,旋轉,縮放,動畫等變化。TextureView必須在硬體加速的視窗中使用。

TextureView被建立後不能直接使用,必須要在它被它新增到ViewGroup後,待SurfaceTexture準備就緒才能起作用(看TextureView的原始碼,TextureView是在繪製的時候建立的內部SurfaceTexture)。通常需要給TextureView設定監聽器SurfaceTextuListener

mTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        // SurfaceTexture準備就緒
    }

    @Override
    public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
        // SurfaceTexture緩衝大小變化
    }

    @Override
    public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
        // SurfaceTexture即將被銷燬
        return false;
    }

    @Override
    public void onSurfaceTextureUpdated(SurfaceTexture surface) {
        // SurfaceTexture通過updateImage更新
    }
});複製程式碼

SurfaceTexture的準備就緒、大小變化、銷燬、更新等狀態變化時都會回撥相對應的方法。當TextureView內部建立好SurfaceTexture後,在監聽器的onSurfaceTextureAvailable方法中,用SurfaceTexture來關聯MediaPlayer,作為播放視訊的影像資料來源。

SurfaceTexture作為資料通道,把從資料來源(MediaPlayer)中獲取到的影像幀資料轉為GL外部紋理,交給TextureVeiw作為View heirachy中的一個硬體加速層來顯示,從而實現視訊播放功能。

MediaPlayer介紹

MediaPlayer是Android原生的多媒體播放器,可以用它來實現本地或者線上音視訊的播放,同時它支援https和rtsp

MediaPlayer定義了各種狀態,可以理解為是它的生命週期。

MediaPlayer狀態圖(生命週期)

這個狀態圖描述了MediaPlayer的各種狀態,以及主要方法呼叫後的狀態變化。

MediaPlayer的相關方法及監聽介面:

方法 介紹 狀態
setDataSource 設定資料來源 Initialized
prepare 準備播放,同步 Preparing —> Prepared
prepareAsync 準備播放,非同步 Preparing —> Prepared
start 開始或恢復播放 Started
pause 暫停 Paused
stop 停止 Stopped
seekTo 到指定時間點位置 PrePared/Started
reset 重置播放器 Idle
setAudioStreamType 設定音訊流型別 --
setDisplay 設定播放視訊的Surface --
setVolume 設定聲音 --
getBufferPercentage 獲取緩衝半分比 --
getCurrentPosition 獲取當前播放位置 --
getDuration 獲取播放檔案總時間 --
內部回撥介面 介紹 狀態
OnPreparedListener 準備監聽 Preparing ——>Prepared
OnVideoSizeChangedListener 視訊尺寸變化監聽 --
OnInfoListener 指示資訊和警告資訊監聽 --
OnCompletionListener 播放完成監聽 PlaybackCompleted
OnErrorListener 播放錯誤監聽 Error
OnBufferingUpdateListener 緩衝更新監聽 --

MediaPlayer在直接new出來之後就進入了Idle狀態,此時可以呼叫多個過載的setDataSource()方法從idle狀態進入Initialized狀態(如果呼叫setDataSource()方法的時候,MediaPlayer物件不是出於Idle狀態,會拋異常,可以呼叫reset()方法回到Idle狀態)。

呼叫prepared()方法和preparedAsync()方法進入Prepared狀態,prepared()方法直接進入Parpared狀態,preparedAsync()方法會先進入PreParing狀態,播放引擎準備完畢後會通過OnPreparedListener.onPrepared()回撥方法通知Prepared狀態。

在Prepared狀態下就可以呼叫start()方法進行播放了,此時進入started()狀態,如果播放的是網路資源,Started狀態下也會自動呼叫客戶端註冊的OnBufferingUpdateListener.OnBufferingUpdate()回撥方法,對流播放緩衝的狀態進行追蹤。

pause()方法和start()方法是對應的,呼叫pause()方法會進入Paused狀態,呼叫start()方法重新進入Started狀態,繼續播放。

stop()方法會使MdiaPlayer從Started、Paused、Prepared、PlaybackCompleted等狀態進入到Stoped狀態,播放停止。

當資源播放完畢時,如果呼叫了setLooping(boolean)方法,會自動進入Started狀態重新播放,如果沒有呼叫則會自動呼叫客戶端播放器註冊的OnCompletionListener.OnCompletion()方法,此時MediaPlayer進入PlaybackCompleted狀態,在此狀態裡可以呼叫start()方法重新進入Started狀態。

封裝考慮

MediaPlayer的方法和介面比較多,不同的狀態呼叫各個方法後狀態變化情況也比較複雜。播放相關的邏輯只與MediaPlayer的播放狀態和呼叫方法相關,而介面展示和UI操作很多時候都需要根據自己專案來定製。參考原生的VideoView,為了解耦和方便定製,把MediaPlayer的播放邏輯和UI介面展示及操作相關的邏輯分離。我是把MediaPlayer直接封裝到NiceVideoPlayer中,各種UI狀態和操作反饋都封裝到NiceVideoPlayerController裡面。如果需要根據不同的專案需求來修改播放器的功能,就只重寫NiceVideoPlayerController就可以了。

NiceVideoPlayer

首先,需要一個FrameLayout容器mContainer,裡面有兩層內容,第一層就是展示播放視訊內容的TextureView,第二層就是播放器控制器mController。那麼自定義一個NiceVideoPlayer繼承自FrameLayout,將mContainer新增到當前控制元件:

public class NiceVideoPlayer extends FrameLayout{

    private Context mContext;
    private NiceVideoController mController;
    private FrameLayout mContainer;

    public NiceVideoPlayer(Context context) {
        this(context, null);
    }

    public NiceVideoPlayer(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        init();
    }

    private void init() {
         mContainer = new FrameLayout(mContext);
         mContainer.setBackgroundColor(Color.BLACK);
         LayoutParams params = new LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
        this.addView(mContainer, params);
    }
}複製程式碼

新增setUp方法來配置播放的視訊資源路徑(本地/網路資源):

public void setUp(String url, Map headers) {
        mUrl = url;
        mHeaders = headers;
    }複製程式碼

使用者要在mController中操作才能播放,因此需要在播放之前設定好mController:

public void setController(NiceVideoPlayerController controller) {
    mController = controller;
    mController.setNiceVideoPlayer(this);
    LayoutParams params = new LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT);
    mContainer.addView(mController, params);
}複製程式碼

使用者在自定義好自己的控制器後通過setController這個方法設定給播放器進行關聯。

觸發播放時,NiceVideoPlayer將展示視訊影像內容的mTextureView新增到mContainer中(在mController的下層),同時初始化mMediaPlayer,待mTextureView的資料通道SurfaceTexture準備就緒後就可以開啟播放器:

public void start() {
    initMediaPlayer();  // 初始化播放器
    initTextureView();  // 初始化展示視訊內容的TextureView
    addTextureView();   // 將TextureView新增到容器中
}

private void initTextureView() {
    if (mTextureView == null) {
        mTextureView = new TextureView(mContext);
        mTextureView.setSurfaceTextureListener(this);
    }
}

private void addTextureView() {
    mContainer.removeView(mTextureView);
    LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT);
    mContainer.addView(mTextureView, 0,  params);
}

private void initMediaPlayer() {
    if (mMediaPlayer == null) {
        mMediaPlayer = new MediaPlayer();

        mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
        mMediaPlayer.setScreenOnWhilePlaying(true);

        mMediaPlayer.setOnPreparedListener(mOnPreparedListener);
        mMediaPlayer.setOnVideoSizeChangedListener(mOnVideoSizeChangedListener);
        mMediaPlayer.setOnCompletionListener(mOnCompletionListener);
        mMediaPlayer.setOnErrorListener(mOnErrorListener);
        mMediaPlayer.setOnInfoListener(mOnInfoListener);
        mMediaPlayer.setOnBufferingUpdateListener(mOnBufferingUpdateListener);
    }
}

@Override
public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
    // surfaceTexture資料通道準備就緒,開啟播放器
    openMediaPlayer(surface);
}

private void openMediaPlayer(SurfaceTexture surface) {
    try {
        mMediaPlayer.setDataSource(mContext.getApplicationContext(), Uri.parse(mUrl), mHeaders);
        mMediaPlayer.setSurface(new Surface(surface));
        mMediaPlayer.prepareAsync();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

@Override
public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {

}

@Override
public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
    return false;
}

@Override
public void onSurfaceTextureUpdated(SurfaceTexture surface) {

}複製程式碼

開啟播放器呼叫prepareAsync()方法後,mMediaPlayer進入準備狀態,準備就緒後就可以開始:

private MediaPlayer.OnPreparedListener mOnPreparedListener
        = new MediaPlayer.OnPreparedListener() {
    @Override
    public void onPrepared(MediaPlayer mp) {
        mp.start();
    }
};複製程式碼

NiceVideoPlayer的這些邏輯已經實現視訊播放了,操作相關以及UI展示的邏輯需要在控制器NiceVideoPlayerController中來實現。但是呢,UI的展示和反饋都需要依據播放器當前的播放狀態,所以需要給播放器定義一些常量來表示它的播放狀態:

public static final int STATE_ERROR = -1;          // 播放錯誤
public static final int STATE_IDLE = 0;            // 播放未開始
public static final int STATE_PREPARING = 1;       // 播放準備中
public static final int STATE_PREPARED = 2;        // 播放準備就緒
public static final int STATE_PLAYING = 3;         // 正在播放
public static final int STATE_PAUSED = 4;          // 暫停播放
// 正在緩衝(播放器正在播放時,緩衝區資料不足,進行緩衝,緩衝區資料足夠後恢復播放)
public static final int STATE_BUFFERING_PLAYING = 5;
// 正在緩衝(播放器正在播放時,緩衝區資料不足,進行緩衝,此時暫停播放器,繼續緩衝,緩衝區資料足夠後恢復暫停)
public static final int STATE_BUFFERING_PAUSED = 6;
public static final int STATE_COMPLETED = 7;       // 播放完成複製程式碼

播放視訊時,mMediaPlayer準備就緒(Prepared)後沒有馬上進入播放狀態,中間有一個時間延遲時間段,然後開始渲染影像。所以將Prepared——>“開始渲染”中間這個時間段定義為STATE_PREPARED

如果是播放網路視訊,在播放過程中,緩衝區資料不足時mMediaPlayer內部會停留在某一幀畫面以進行緩衝。正在緩衝時,mMediaPlayer可能是在正在播放也可能是暫停狀態,因為在緩衝時如果使用者主動點選了暫停,就是處於STATE_BUFFERING_PAUSED,所以緩衝有STATE_BUFFERING_PLAYINGSTATE_BUFFERING_PAUSED兩種狀態,緩衝結束後,恢復播放或暫停。

private MediaPlayer.OnPreparedListener mOnPreparedListener
        = new MediaPlayer.OnPreparedListener() {
    @Override
    public void onPrepared(MediaPlayer mp) {
        mp.start();
        mCurrentState = STATE_PREPARED;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("onPrepared ——> STATE_PREPARED");
    }
};

private MediaPlayer.OnVideoSizeChangedListener mOnVideoSizeChangedListener
        = new MediaPlayer.OnVideoSizeChangedListener() {
    @Override
    public void onVideoSizeChanged(MediaPlayer mp, int width, int height) {
        LogUtil.d("onVideoSizeChanged ——> width:" + width + ",height:" + height);
    }
};

private MediaPlayer.OnCompletionListener mOnCompletionListener
        = new MediaPlayer.OnCompletionListener() {
    @Override
    public void onCompletion(MediaPlayer mp) {
        mCurrentState = STATE_COMPLETED;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("onCompletion ——> STATE_COMPLETED");
    }
};

private MediaPlayer.OnErrorListener mOnErrorListener
        = new MediaPlayer.OnErrorListener() {
    @Override
    public boolean onError(MediaPlayer mp, int what, int extra) {
        mCurrentState = STATE_ERROR;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("onError ——> STATE_ERROR ———— what:" + what);
        return false;
    }
};

private MediaPlayer.OnInfoListener mOnInfoListener
        = new MediaPlayer.OnInfoListener() {
    @Override
    public boolean onInfo(MediaPlayer mp, int what, int extra) {
        if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) {
            // 播放器渲染第一幀
            mCurrentState = STATE_PLAYING;
            mController.setControllerState(mPlayerState, mCurrentState);
            LogUtil.d("onInfo ——> MEDIA_INFO_VIDEO_RENDERING_START:STATE_PLAYING");
        } else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
            // MediaPlayer暫時不播放,以緩衝更多的資料
            if (mCurrentState == STATE_PAUSED || mCurrentState == STATE_BUFFERING_PAUSED) {
                mCurrentState = STATE_BUFFERING_PAUSED;
                LogUtil.d("onInfo ——> MEDIA_INFO_BUFFERING_START:STATE_BUFFERING_PAUSED");
            } else {
                mCurrentState = STATE_BUFFERING_PLAYING;
                LogUtil.d("onInfo ——> MEDIA_INFO_BUFFERING_START:STATE_BUFFERING_PLAYING");
            }
            mController.setControllerState(mPlayerState, mCurrentState);
        } else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
            // 填充緩衝區後,MediaPlayer恢復播放/暫停
            if (mCurrentState == STATE_BUFFERING_PLAYING) {
                mCurrentState = STATE_PLAYING;
                mController.setControllerState(mPlayerState, mCurrentState);
                LogUtil.d("onInfo ——> MEDIA_INFO_BUFFERING_END: STATE_PLAYING");
            }
            if (mCurrentState == STATE_BUFFERING_PAUSED) {
                mCurrentState = STATE_PAUSED;
                mController.setControllerState(mPlayerState, mCurrentState);
                LogUtil.d("onInfo ——> MEDIA_INFO_BUFFERING_END: STATE_PAUSED");
            }
        } else {
            LogUtil.d("onInfo ——> what:" + what);
        }
        return true;
    }
};

private MediaPlayer.OnBufferingUpdateListener mOnBufferingUpdateListener
        = new MediaPlayer.OnBufferingUpdateListener() {
    @Override
    public void onBufferingUpdate(MediaPlayer mp, int percent) {
        mBufferPercentage = percent;
    }
};複製程式碼

mController.setControllerState(mPlayerState, mCurrentState)mCurrentState表示當前播放狀態,mPlayerState表示播放器的全屏、小視窗,正常三種狀態。

public static final int PLAYER_NORMAL = 10;        // 普通播放器
public static final int PLAYER_FULL_SCREEN = 11;   // 全屏播放器
public static final int PLAYER_TINY_WINDOW = 12;   // 小視窗播放器複製程式碼

定義好播放狀態後,開始暫停等操作邏輯也需要根據播放狀態調整:

@Override
public void start() {
    if (mCurrentState == STATE_IDLE
            || mCurrentState == STATE_ERROR
            || mCurrentState == STATE_COMPLETED) {
        initMediaPlayer();
        initTextureView();
        addTextureView();
    }
}

@Override
public void restart() {
    if (mCurrentState == STATE_PAUSED) {
        mMediaPlayer.start();
        mCurrentState = STATE_PLAYING;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("STATE_PLAYING");
    }
    if (mCurrentState == STATE_BUFFERING_PAUSED) {
        mMediaPlayer.start();
        mCurrentState = STATE_BUFFERING_PLAYING;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("STATE_BUFFERING_PLAYING");
    }
}

@Override
public void pause() {
    if (mCurrentState == STATE_PLAYING) {
        mMediaPlayer.pause();
        mCurrentState = STATE_PAUSED;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("STATE_PAUSED");
    }
    if (mCurrentState == STATE_BUFFERING_PLAYING) {
        mMediaPlayer.pause();
        mCurrentState = STATE_BUFFERING_PAUSED;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("STATE_BUFFERING_PAUSED");
    }
}複製程式碼

reStart()方法是暫停時繼續播放呼叫。

全屏、小視窗播放的實現

可能最能想到實現全屏的方式就是把當前播放器的寬高給放大到螢幕大小,同時隱藏除播放器以外的其他所有UI,並設定成橫屏模式。但是這種方式有很多問題,比如在列表(ListView或RecyclerView)中,除了放大隱藏外,還需要去計算滑動多少距離才剛好讓播放器與螢幕邊緣重合,退出全屏的時候還需要滑動到之前的位置,這樣實現邏輯不但繁瑣,而且和外部UI偶合嚴重,後面改動維護起來非常困難(我曾經就用這種方式被坑了無數道)。

分析能不能有其他更好的實現方式呢?

整個播放器由mMediaPalyer+mTexutureView+mController組成,要實現全屏或小視窗播放,我們只需要挪動播放器的展示介面mTexutureView和控制介面mController即可。並且呢我們在上面定義播放器時,已經把mTexutureViewmController一起新增到mContainer中了,所以只需要將mContainer從當前檢視中移除,並新增到全屏和小視窗的目標檢視中即可。

那麼怎麼確定全屏和小視窗的目標檢視呢?

我們知道每個Activity裡面都有一個android.R.content,它是一個FrameLayout,裡面包含了我們setContentView的所有控制元件。既然它是一個FrameLayout,我們就可以將它作為全屏和小視窗的目標檢視。

我們把從當前檢視移除的mContainer重新新增到android.R.content中,並且設定成橫屏。這個時候還需要注意android.R.content是不包括ActionBar和狀態列的,所以要將Activity設定成全屏模式,同時隱藏ActionBar

@Override
public void enterFullScreen() {
    if (mPlayerState == PLAYER_FULL_SCREEN) return;

    // 隱藏ActionBar、狀態列,並橫屏
    NiceUtil.hideActionBar(mContext);
    NiceUtil.scanForActivity(mContext)
            .setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);

    this.removeView(mContainer);
    ViewGroup contentView = (ViewGroup) NiceUtil.scanForActivity(mContext)
            .findViewById(android.R.id.content);
    LayoutParams params = new LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT);
    contentView.addView(mContainer, params);

    mPlayerState = PLAYER_FULL_SCREEN;
    mController.setControllerState(mPlayerState, mCurrentState);
    LogUtil.d("PLAYER_FULL_SCREEN");
}複製程式碼

退出全屏也就很簡單了,將mContainerandroid.R.content中移除,重新新增到當前檢視,並恢復ActionBar、清除全屏模式就行了。

@Override
public boolean exitFullScreen() {
    if (mPlayerState == PLAYER_FULL_SCREEN) {
        NiceUtil.showActionBar(mContext);
        NiceUtil.scanForActivity(mContext)
                .setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);

        ViewGroup contentView = (ViewGroup) NiceUtil.scanForActivity(mContext)
                .findViewById(android.R.id.content);
        contentView.removeView(mContainer);
        LayoutParams params = new LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
        this.addView(mContainer, params);

        mPlayerState = PLAYER_NORMAL;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("PLAYER_NORMAL");
        return true;
    }
    return false;
}複製程式碼

切換橫豎屏時為了避免Activity重新走生命週期,別忘了需要在Manifest.xmlactivity標籤下新增如下配置:

android:configChanges="orientation|keyboardHidden|screenSize"複製程式碼

進入小視窗播放和退出小視窗的實現原理就和全屏功能一樣了,只需要修改它的寬高引數:

@Override
public void enterTinyWindow() {
    if (mPlayerState == PLAYER_TINY_WINDOW) return;

    this.removeView(mContainer);

    ViewGroup contentView = (ViewGroup) NiceUtil.scanForActivity(mContext)
            .findViewById(android.R.id.content);
    // 小視窗的寬度為螢幕寬度的60%,長寬比預設為16:9,右邊距、下邊距為8dp。
    FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
            (int) (NiceUtil.getScreenWidth(mContext) * 0.6f),
            (int) (NiceUtil.getScreenWidth(mContext) * 0.6f * 9f / 16f));
    params.gravity = Gravity.BOTTOM | Gravity.END;
    params.rightMargin = NiceUtil.dp2px(mContext, 8f);
    params.bottomMargin = NiceUtil.dp2px(mContext, 8f);

    contentView.addView(mContainer, params);

    mPlayerState = PLAYER_TINY_WINDOW;
    mController.setControllerState(mPlayerState, mCurrentState);
    LogUtil.d("PLAYER_TINY_WINDOW");
}

@Override
public boolean exitTinyWindow() {
    if (mPlayerState == PLAYER_TINY_WINDOW) {
        ViewGroup contentView = (ViewGroup) NiceUtil.scanForActivity(mContext)
                .findViewById(android.R.id.content);
        contentView.removeView(mContainer);
        LayoutParams params = new LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
        this.addView(mContainer, params);

        mPlayerState = PLAYER_NORMAL;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("PLAYER_NORMAL");
        return true;
    }
    return false;
}複製程式碼

這裡有個特別需要注意的一點:

mContainer移除重新新增後,mContainer及其內部的mTextureViewmController都會重繪,mTextureView重繪後,會重新new一個SurfaceTexture,並重新回撥onSurfaceTextureAvailable方法,這樣mTextureView的資料通道SurfaceTexture發生了變化,但是mMediaPlayer還是持有原先的mSurfaceTexut,所以在切換全屏之前要儲存之前的mSufaceTexture,當切換到全屏後重新呼叫onSurfaceTextureAvailable時,將之前的mSufaceTexture重新設定給mTexutureView。這樣講保證了切換時視訊播放的無縫銜接。

@Override
public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) {
    if (mSurfaceTexture == null) {
        mSurfaceTexture = surfaceTexture;
        openMediaPlayer();
    } else {
        mTextureView.setSurfaceTexture(mSurfaceTexture);
    }
}複製程式碼

NiceVideoPlayerControl

為了解除NiceVideoPlayerNiceVideoPlayerController的耦合,把NiceVideoPlayer的一些功能性和判斷性方法抽象到NiceVideoPlayerControl介面中。

public interface NiceVideoPlayerControl {

    void start();
    void restart();
    void pause();
    void seekTo(int pos);

    boolean isIdle();
    boolean isPreparing();
    boolean isPrepared();
    boolean isBufferingPlaying();
    boolean isBufferingPaused();
    boolean isPlaying();
    boolean isPaused();
    boolean isError();
    boolean isCompleted();

    boolean isFullScreen();
    boolean isTinyWindow();
    boolean isNormal();

    int getDuration();
    int getCurrentPosition();
    int getBufferPercentage();

    void enterFullScreen();
    boolean exitFullScreen();
    void enterTinyWindow();
    boolean exitTinyWindow();

    void release();
}複製程式碼

NiceVideoPlayer實現這個介面即可。

NiceVideoPlayerManager

同一介面上有多個視訊,或者視訊放在ReclerView或者ListView的容器中,要保證同一時刻只有一個視訊在播放,其他的都是初始狀態,所以需要一個NiceVideoPlayerManager來管理播放器,主要功能是儲存當前已經開始了的播放器。

public class NiceVideoPlayerManager {

    private NiceVideoPlayer mVideoPlayer;

    private NiceVideoPlayerManager() {
    }

    private static NiceVideoPlayerManager sInstance;

    public static synchronized NiceVideoPlayerManager instance() {
        if (sInstance == null) {
            sInstance = new NiceVideoPlayerManager();
        }
        return sInstance;
    }

    public void setCurrentNiceVideoPlayer(NiceVideoPlayer videoPlayer) {
        mVideoPlayer = videoPlayer;
    }

    public void releaseNiceVideoPlayer() {
        if (mVideoPlayer != null) {
            mVideoPlayer.release();
            mVideoPlayer = null;
        }
    }

    public boolean onBackPressd() {
        if (mVideoPlayer != null) {
            if (mVideoPlayer.isFullScreen()) {
                return mVideoPlayer.exitFullScreen();
            } else if (mVideoPlayer.isTinyWindow()) {
                return mVideoPlayer.exitTinyWindow();
            } else {
                mVideoPlayer.release();
                return false;
            }
        }
        return false;
    }
}複製程式碼

採用單例,同時,onBackPressedActivity中使用者按返回鍵時呼叫。
NiceVideoPlayerstart方法以及onCompleted需要修改一下,保證開始播放一個視訊時要先釋放掉之前的播放器;同時自己播放完畢,要將NiceVideoPlayerManager中的mNiceVideoPlayer例項置空,避免記憶體洩露。

// NiceVideoPlayer的start()方法。
@Override
public void start() {
    NiceVideoPlayerManager.instance().releaseNiceVideoPlayer();
    NiceVideoPlayerManager.instance().setCurrentNiceVideoPlayer(this);
    if (mCurrentState == STATE_IDLE
            || mCurrentState == STATE_ERROR
            || mCurrentState == STATE_COMPLETED) {
        initMediaPlayer();
        initTextureView();
        addTextureView();
    }
}

// NiceVideoPlayer中的onCompleted監聽。
private MediaPlayer.OnCompletionListener mOnCompletionListener
        = new MediaPlayer.OnCompletionListener() {
    @Override
    public void onCompletion(MediaPlayer mp) {
        mCurrentState = STATE_COMPLETED;
        mController.setControllerState(mPlayerState, mCurrentState);
        LogUtil.d("onCompletion ——> STATE_COMPLETED");
        NiceVideoPlayerManager.instance().setCurrentNiceVideoPlayer(null);
    }
};複製程式碼

NiceVideoPlayerController

播放控制介面上,播放、暫停、播放進度、緩衝動畫、全屏/小屏等觸發都是直接呼叫播放器對應的操作的。需要注意的就是呼叫之前要判斷當前的播放狀態,因為有些狀態下呼叫播放器的操作可能引起錯誤(比如播放器還沒準備就緒,就去獲取當前的播放位置)。

播放器在觸發相應功能的時候都會呼叫NiceVideoPlayerControllersetControllerState(int playerState, int playState)這個方法來讓使用者修改UI。

不同專案都可能定製不同的控制器(播放操作介面),這裡我就不詳細分析實現邏輯了,大致功能就類似騰訊視訊的熱點列表中的播放器。其中全屏模式下橫向滑動改變播放進度、左側上下滑動改變亮度,右側上下滑動改變亮度等功能程式碼中並未實現,有需要的可以直接參考節操播放器,只需要在ControlleronInterceptTouchEvent中處理就行了(後續會新增上去)。

程式碼有點長,就不貼了,需要的直接下載原始碼

使用

mNiceVideoPlayer.setUp(url, null);
NiceVideoPlayerController controller = new NiceVideoPlayerController(this);
controller.setTitle(title);
controller.setImage(imageUrl);
mNiceVideoPlayer.setController(controller);複製程式碼

RecyclerView或者ListView中使用時,需要監聽itemViewdetached

mRecyclerView.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() {
    @Override
    public void onChildViewAttachedToWindow(View view) {

    }

    @Override
    public void onChildViewDetachedFromWindow(View view) {
        NiceVideoPlayer niceVideoPlayer = (NiceVideoPlayer) view.findViewById(R.id.nice_video_player);
        if (niceVideoPlayer != null) {
            niceVideoPlayer.release();
        }
    }
});複製程式碼

ItemViewdetach視窗時,需要釋放掉itemView內部的播放器。

效果圖

最後

整個功能有參考節操播放器,但是自己這樣封裝和節操播放器還是有很大差異:一是分離了播放功能和控制介面,定製只需修改控制器即可。二是全屏/小視窗沒有新建一個播放器,只是挪動了播放介面和控制器,不用每個視訊都需要新建兩個播放器,也不用同步狀態。


MediaPlayer有很多格式不支援,專案已新增IjkPlayer的擴充套件支援,可以切換IjkPlayer和原生MediaPlayer,後續還會考慮新增ExoPlayer,同時也會擴充套件更多功能。

如果有錯誤和更好的建議都請提出,原始碼已上傳GitHub,歡迎Star,謝謝!。

原始碼:github.com/xiaoyanger0…


參考:
Android TextureView簡易教程
視訊畫面幀的展示控制元件SurfaceView及TextureView對比
Android 5.0(Lollipop)中的SurfaceTexture,TextureView, SurfaceView和GLSurfaceView
Android MediaPlayer生命週期詳解
節操播放器 https://github.com/lipangit/JieCaoVideoPlayer

相關文章