查看原文
其他

SurfaceView+MediaPlayer封装之路

2017-09-27 qzs 安卓干货铺

作者 | jiashuai94

地址 | https://github.com/shuaijia/JsPlayer

博客 | http://blog.csdn.net/jiashuai94

声明 | 本文是 jiashuai94 原创,已获授权发布,未经原作者允许请勿转载


我的播放器叫做JsPlayer,喜欢的话,就给个star喽
这里我只介绍播放器封装思路,会贴出部分代码,如果大家想查看完整代码,可以去Github查看,有不清楚或错误或改进的地方,可以issues 我!

先上效果图:(1.5版本新增弹幕功能


关于更多SurfaceView的介绍,可参考我写的另一片文章:http://blog.csdn.net/jiashuai94/article/details/77882644

播放器结构

UML图

已经对SurfaceView+MediaPlayer封装视屏播放器有了大致的了解,接下来就开始视屏播放

器的封装之旅吧!

一.工具类

工欲善其事,必先利其器!

想封装结构清晰,使用方便的视频播放器,工具类是少不了的!JsPlayer主要用了以下几个工具类:

  • DisplayUtils

  • NetworkUtils

  • StringUtils

DisplayUtils:负责界面展示相关工具,例如px、dp、sp的相互转换;获取屏幕宽高度;

切换横屏、竖屏等;

NetworkUtils:判断手机是否联网;是否为wifi;是否是流量;网络状态等;

StringUtils:主要将long型毫秒转换为时间格式的字符串。

代码就不贴了,很简单。大家想了解,去github中查看吧。

二.实体类

为了在使用视频播放器时规范传入的数据,同时也方便使用者调用和封装,故定义了视频详情的接口:其包含两个抽象方法,分别返回视频地址和视频标题。

/** * 视频数据类 * 请实现本接口 */ public interface IVideoInfo extends Serializable {    /**     * 视频标题     */    String getVideoTitle();    /**     * 视频播放路径(本地或网络)     */    String getVideoPath(); }

用户可根据项目实际情况对其进行扩展(需实现此接口即可),比如默认图地址,点赞数,是否购买,弹幕信息等等。但视频标题和视频地址必须返回

三.回调相关

大家都知道,VideoView或其他视频播放器在使用时,有准备好监听、播放完成监听、错误监听等等,可供开发者在对应情况进行对应处理;而且我们有时也需要在用户点击播放暂停、全屏、拖动进度条等情况下获得操作回调。因此,我们封装了两个回调接口:

  • OnVideoControlListener:视频控制回调

  • OnPlayerCallback:视频状态回调

/** * 视频控制监听 */ public interface OnVideoControlListener {    /**     * 开始播放按钮     */    void onStartPlay();    /**     * 返回     */    void onBack();    /**     * 全屏     */    void onFullScreen();    /**     * 错误后的重试     */    void onRetry(int errorStatus); }

/** * 视频操作回调,是将系统MediaPlayer的常见回调封装 */ public interface OnPlayerCallback {    /**     * 准备好     */    void onPrepared(MediaPlayer mp);    /**     * 视频size变化     */    void onVideoSizeChanged(MediaPlayer mp, int width, int height);    /**     * 缓存更新变化     *     * @param percent 缓冲百分比     */    void onBufferingUpdate(MediaPlayer mp, int percent);    /**     * 播放完成     */    void onCompletion(MediaPlayer mp);    /**     * 视频错误     * @param what  错误类型     * @param extra 特殊错误码     */    void onError(MediaPlayer mp, int what, int extra);    /**     * 视频加载状态变化     *     * @param isShow 是否显示loading     */    void onLoadingChanged(boolean isShow);    /**     * 视频状态变化     */    void onStateChanged(int curState); }

当然了,各位使用上述两个回调时,必须先实现、再使用,当然也可以基于它拓展了!

四.自定义view

关于播放器中涉及到的、需要自定义的view主要有手势调节进度、音量、亮度时的弹框、控制器界面、错误界面。当然我们的JsPlayer视频播放器也是一自定义view,其手势控制也封装了一个view,这些我们稍后会详细介绍。

  • JsVideoProgressOverlay: 调节进度 框

  • JsVideoSystemOverlay: 调节音量、亮度 框

  • JsVideoErrorView: 错误界面

  • JsVideoControllerView: 控制器

我的思路是这样的:将错误界面JsVideoErrorView再封装到控制器中JsVideoControllerView,这样便于在出错时的处理;而调节进度等弹框、控制器,当然还有SurfaceView,加载中等,它们会一同封装到视频播放器JsPlayer的自定义View中。

JsVideoProgressOverlay

因为字数的限制,详细的请点击阅读原文看我的博客和Github。

JsVideoErrorView

因为字数的限制,详细的请点击阅读原文看我的博客和Github。

JsVideoControllerView

因为字数的限制,详细的请点击阅读原文看我的博客和Github。

五.MediaPlayer封装

主要封装了

  • openVideo:播放视频,处理各回调

  • start:开始播放

  • pause:暂停播放

  • seekTo:定位到

  • reset:视频重置

  • stop:停止播放

  • isPlaying:是否正在播放

  • getDuration:获取总时长

  • getCurrentPosition:获取当前进度

  • getBufferPercentage:获取缓冲进度等

定义了视频播放的所用状态值常量

   //出错状态    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_PLAYBACK_COMPLETED = 5;

   // 播放核心使用MediaPlayer    private MediaPlayer player;    // 当前状态    private int curState = STATE_IDLE;    // 当前缓冲进度    private int currentBufferPercentage;    // *视频路径    private String path;    // 播放监听    private OnPlayerCallback onPlayerListener;    // 播放视频承载的view    private SurfaceHolder surfaceHolder;

封装了视频播放状态的判断

   public boolean isInPlaybackState() {        return (player != null &&                curState != STATE_ERROR &&                curState != STATE_IDLE &&                curState != STATE_PREPARING);    }

此方法会在其他的所有方法执行之前判断,如果返回false,则不进行开始播放、重新播放、拖动定位等操作。

同时这些操作执行完后都会更新当前播放状态,防止视频不能播的情况下操作报错。如

   /**     * 开始播放     */    public void start() {        if (isInPlaybackState()) {            player.start();            setCurrentState(STATE_PLAYING);        }    }

在openVideo中:

   public void openVideo() {        if (path == null || surfaceHolder == null) {            return;        }        reset();        player = new MediaPlayer();        // 准备好的监听        player.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {            @Override            public void onPrepared(MediaPlayer mp) {                //因为后面播放时要判断当前视频状态,所以在此一定要先将状态改变为STATE_PREPARED                //即已经准备好,否则在第一次打开视频时无法自动播放                setCurrentState(STATE_PREPARED);                if (onPlayerListener != null) {                    onPlayerListener.onPrepared(mp);                }            }        });        // 缓冲监听        player.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() {            @Override            public void onBufferingUpdate(MediaPlayer mp, int percent) {                if (onPlayerListener != null) {                    onPlayerListener.onBufferingUpdate(mp, percent);                }                currentBufferPercentage = percent;            }        });        // 播放完成监听        player.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {            @Override            public void onCompletion(MediaPlayer mp) {                if (onPlayerListener != null) {                    onPlayerListener.onCompletion(mp);                }                setCurrentState(STATE_PLAYBACK_COMPLETED);            }        });        // 信息监听        player.setOnInfoListener(new MediaPlayer.OnInfoListener() {            @Override            public boolean onInfo(MediaPlayer mp, int what, int extra) {                if (onPlayerListener != null) {                    // 701 加载中                    if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {                        onPlayerListener.onLoadingChanged(true);                        // 702 加载完成                    } else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {                        onPlayerListener.onLoadingChanged(false);                    }                }                return false;            }        });        // 出错监听        player.setOnErrorListener(onErrorListener);        // 视频大小切换监听        player.setOnVideoSizeChangedListener(new MediaPlayer.OnVideoSizeChangedListener() {            @Override            public void onVideoSizeChanged(MediaPlayer mp, int width, int height) {                if (onPlayerListener != null) {                    onPlayerListener.onVideoSizeChanged(mp, width, height);                }            }        });        currentBufferPercentage = 0;        try {            /**             * 在这里开始真正的播放             */            player.setDataSource(path);            player.setDisplay(surfaceHolder);            player.setAudioStreamType(AudioManager.STREAM_MUSIC);            player.setScreenOnWhilePlaying(true);            player.prepareAsync();            Log.e(TAG, "openVideo: " );            setCurrentState(STATE_PREPARING);        } catch (Exception e) {            Log.e(TAG, "openVideo: " + e.toString());            setCurrentState(STATE_ERROR);            onErrorListener.onError(player, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0);        }    }

openVideo就是播放视频的核心方法:新建MediaPlayer对象;将视频播放的各回调交给OnPlayerCallback处理;将外部传进来的SurfaceHolder设置给MediaPlayer,并且prepareAsync之后就可以播放了,当然,不要忘了更新状态!

六.手势控制

说到手势控制,主要是手势控制视频进度,手势控制音量和屏幕亮度。对于手势控制,我自定义了BehaviorView:让其实现GestureDetector的OnGestureListener

public class VideoBehaviorView extends FrameLayout implements GestureDetector.OnGestureListener{

在此view中定义以下方法,实现更新UI,交由子类去复写

   // 更新进度UI,由子类重写    protected void updateSeekUI(int delProgress) {        // sub    }    // 更新音量UI,由子类重写    protected void updateVolumeUI(int max, int progress) {        // sub    }    // 更新亮度UI,由子类重写    protected void updateLightUI(int max, int progress) {        // sub    }

我的思路是将view的触摸事件全部交给GestureDetector处理

   @Override    public boolean onTouchEvent(MotionEvent event) {        mGestureDetector.onTouchEvent(event);        switch (event.getAction() & MotionEvent.ACTION_MASK) {            case MotionEvent.ACTION_UP:            case MotionEvent.ACTION_OUTSIDE:            case MotionEvent.ACTION_CANCEL:                endGesture(mFingerBehavior);                break;        }        return true;    }

当手指按下时,重置手指行为,获取当前音量、亮度

   @Override    public boolean onDown(MotionEvent e) {        //重置 手指行为        mFingerBehavior = -1;        mCurrentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC);        try {            mCurrentBrightness = (int) (activity.getWindow().getAttributes().screenBrightness * mMaxBrightness);        } catch (Exception exception) {            exception.printStackTrace();        }        return false;    }

在onScroll方法中:

判断决定当前为何种类型手势:左右滑动为调节进度,左半屏上下滑动为调节亮度,右半屏上下滑动为调节音量

       /**         * 根据手势起始2个点断言 后续行为. 规则如下:         *  屏幕切分为:         *  1.左右扇形区域为视频进度调节         *  2.上下扇形区域 左半屏亮度调节 后半屏音量调节.         */        if (mFingerBehavior < 0) {            float moveX = e2.getX() - e1.getX();            float moveY = e2.getY() - e1.getY();            // 如果横向滑动距离大于纵向滑动距离,则认为在调节进度            if (Math.abs(moveX) >= Math.abs(moveY))                mFingerBehavior = FINGER_BEHAVIOR_PROGRESS;                // 否则为调节音量或亮度                // 按下位置在屏幕左半边,则是调节亮度            else if (e1.getX() <= width / 2) mFingerBehavior = FINGER_BEHAVIOR_BRIGHTNESS;                // 按下位置在屏幕右半边,则是在调节音量            else mFingerBehavior = FINGER_BEHAVIOR_VOLUME;        }

手势处理

       switch (mFingerBehavior) {            case FINGER_BEHAVIOR_PROGRESS: { // 进度变化                // 默认滑动一个屏幕 视频移动八分钟.                int delProgress = (int) (1.0f * distanceX / width * 480 * 1000);                // 更新快进弹框                updateSeekUI(delProgress);                break;            }            case FINGER_BEHAVIOR_VOLUME: { // 音量变化                float progress = mMaxVolume * (distanceY / height) + mCurrentVolume;       &nbsp 48 31865 48 15287 0 0 800 0 0:00:39 0:00:19 0:00:20 2868;        // 控制调节临界范围                if (progress <= 0) progress = 0;                if (progress >= mMaxVolume) progress = mMaxVolume;                am.setStreamVolume(AudioManager.STREAM_MUSIC, Math.round(progress), 0);                updateVolumeUI(mMaxVolume, Math.round(progress));                // 更新当前值                mCurrentVolume = progress;                break;            }            case FINGER_BEHAVIOR_BRIGHTNESS: { // 亮度变化                try {                    // 如果系统亮度为自动调节,则改为手动调节                    if (Settings.System.getInt(getContext().getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE)                            == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC) {                        Settings.System.putInt(getContext().getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE,                                Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL);                    }                    int progress = (int) (mMaxBrightness * (distanceY / height) + mCurrentBrightness);                    // 控制调节临界范围                    if (progress <= 0) progress = 0;                    if (progress >= mMaxBrightness) progress = mMaxBrightness;                    Window window = activity.getWindow();                    WindowManager.LayoutParams params = window.getAttributes();                    params.screenBrightness = progress / (float) mMaxBrightness;                    window.setAttributes(params);                    updateLightUI(mMaxBrightness, progress);                    // 更新当前值                    mCurrentBrightness = progress;                } catch (Exception e) {                    e.printStackTrace();                }                break;            }        }

注意:

  • 所有的更新UI操作全部交由子类实现

  • 注意临界范围的控制

  • 控制进度时,百分比最后乘以8分钟,以达到较为适中的用户体验,防止视频时长过大或太小情况下,拖动调节进度变化太过明显或效果不明显。

七.播放器JsPlayer封装

先来看看布局

<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="match_parent">    <SurfaceView        android:id="@+id/video_surface"        android:layout_width="match_parent"        android:layout_height="match_parent" />    <com.jia.jsplayer.view.JsVideoControllerView        android:id="@+id/video_controller"        android:layout_width="match_parent"        android:layout_height="match_parent"/>    <include        android:id="@+id/video_loading"        layout="@layout/video_controller_loading" />    <com.jia.jsplayer.view.JsVideoSystemOverlay        android:id="@+id/video_system_overlay"        android:layout_width="wrap_content"        android:layout_height="wrap_content"        android:layout_centerInParent="true"        android:visibility="gone"/>    <com.jia.jsplayer.view.JsVideoProgressOverlay        android:id="@+id/video_progress_overlay"        android:layout_width="wrap_content"        android:layout_height="wrap_content"        android:layout_centerInParent="true"        android:visibility="gone"/> </RelativeLayout>

JsPlayer视频播放器集成自上一步中的VideoBehaviorView,注意复写VideoBehaviorView的更新UI方法。

   private SurfaceView surfaceView;    private View loadingView;    private JsVideoProgressOverlay progressView;    private JsVideoSystemOverlay systemView;    private JsVideoControllerView mediaController;    private JsMediaPlayer mMediaPlayer;

内置封装过得JsMediaPlayer 对象,控制器、和SurfaceView,还有网络状态广播接收器。初始化player,创建JsMediaPlayer对象,设置视频播放回调处理,然后将其设置给ControllerView。

注意:

  • 在准备好的监听中,mediaPlayer执行开始播放,控制器展示,错误界面隐藏。

  • 在播放出错时控制器检查错误类型并展示

  • 在加载状态发生改变时隐藏和展示加载中

    private void initPlayer() {     mMediaPlayer = new JsMediaPlayer();     // todo 这里可以优化,将这些回调全部暴露出去     mMediaPlayer.setOnPlayerListener(new OnPlayerCallback() {         @Override         public void onPrepared(MediaPlayer mp) {             Log.e(TAG, "onPrepared: " );             mMediaPlayer.start();             mediaController.show();             mediaController.hideErrorView();         }         @Override         public void onVideoSizeChanged(MediaPlayer mp, int width, int height) {         }         @Override         public void onBufferingUpdate(MediaPlayer mp, int percent) {         }         @Override         public void onCompletion(MediaPlayer mp) {             mediaController.updatePausePlay();         }         @Override         public void onError(MediaPlayer mp, int what, int extra) {             mediaController.checkShowError(false);         }         @Override         public void onLoadingChanged(boolean isShow) {             if (isShow) showLoading();             else hideLoading();         }         @Override         public void onStateChanged(int curState) {             switch (curState) {                 case JsMediaPlayer.STATE_IDLE:                     am.abandonAudioFocus(null);                     break;                 case JsMediaPlayer.STATE_PREPARING:                     am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);                     break;             }         }     });     mediaController.setMediaPlayer(mMediaPlayer); }

给SurfaceView设置Callback,返回SurfaceHolder后设置给JsMediaPlayer

       surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() {            @Override            public void surfaceCreated(SurfaceHolder holder) {                Log.e(TAG, "surfaceCreated: " );                initWidth = getWidth();                initHeight = getHeight();                if (mMediaPlayer != null) {                    mMediaPlayer.setSurfaceHolder(holder);                }            }            @Override            public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {            }            @Override            public void surfaceDestroyed(SurfaceHolder holder) {            }        });

设置路径,开始播放

   public void setPath(final IVideoInfo video) {        if (video == null) {            return;        }        mMediaPlayer.reset();        String videoPath = video.getVideoPath();        mediaController.setVideoInfo(video);        mMediaPlayer.setPath(videoPath);    }    public void startPlay(){        mMediaPlayer.openVideo();    }

更新UI

   @Override    protected void updateSeekUI(int delProgress) {        progressView.show(delProgress, mMediaPlayer.getCurrentPosition(), mMediaPlayer.getDuration());    }    @Override    protected void updateVolumeUI(int max, int progress) {        systemView.show(JsVideoSystemOverlay.SystemType.VOLUME, max, progress);    }    @Override    protected void updateLightUI(int max, int progress) {        systemView.show(JsVideoSystemOverlay.SystemType.BRIGHTNESS, max, progress);    }

当然不会忘记封装播放、暂停、停止、定位、获取总时长等等的基本方法,这里就不再累赘。

八.使用

涉及到播放网路视频,权限少不了

   <uses-permission android:name="android.permission.INTERNET" />    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

播放本地视频别忘了6.0权限适配

布局中添加

       player = (JsPlayer) findViewById(R.id.player);        player.setOnVideoControlListener(new OnVideoControlListener() {            @Override            public void onStartPlay() {                player.startPlay();            }            @Override            public void onBack() {            }            @Override            public void onFullScreen() {                DisplayUtils.toggleScreenOrientation(MainActivity.this);            }            @Override            public void onRetry(int errorStatus) {            }        });        player.setPath(new VideoInfo("艺术人生", path));

生命周期绑定

   @Override    protected void onStop() {        super.onStop();        player.onStop();    }    @Override    protected void onDestroy() {        super.onDestroy();        player.onDestroy();    }

全屏操作

   @Override    public void onConfigurationChanged(Configuration newConfig) {        super.onConfigurationChanged(newConfig);        if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {            getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);        } else if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {            getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);        }    }    @Override    public void onBackPressed() {        if (!DisplayUtils.isPortrait(this)) {            if (!player.isLock()) {                DisplayUtils.toggleScreenOrientation(this);            }        } else {            super.onBackPressed();        }    }

注意所在Activity在清单文件中应设置

android:configChanges="orientation|keyboardHidden|screenSize"

这样就ok了,播放器封装完美完成!希望对大家有所帮助!

GitHub地址:

https://github.com/shuaijia/JsPlayer

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存