Android视频播放器封装

写在前面:
  • 因项目需要,需要使用到视频播放相关技术,虽然系统提供了播放器VideoView,但由于各种原因无法满足项目需要,特将播放器封装成库,方便日后项目使用及自定义拓展。 此文章适合未接触过视频播放相关、没有时间来研究视频播放相关、不想写UI交互直接用现成的成熟播放器 的开发者阅读,大神大牛请绕路。
给大家推荐视频播放器iPlayer,支持的特性包括但不限于:
  • 支持网络地址、直播流、本地Assets和Raw音视频资源文件播放
  • 支持IJKPlayer、ExoPlayer、MediaPlayer和其它更多自定义解码器
  • 支持自定义视频解码器、控制器、UI交互组件、视频画面渲染器
  • 支持播放倍速、缩放模式、静音、镜像等功能设置
  • 支持多播放器同时播放、跳转到详情无缝衔接播放
  • 支持重力感应横竖屏旋转及开关设置
  • 支持无权限开启Activity级别窗口播放及全局悬浮窗窗口播放
  • 窗口播放器支持自动吸附、悬停
  • Demo仿抖音播放示例,支持视频缓存、秒播、弹幕交互等
    Github无法访问可访问码云项目地址

一、播放器框架设计

iPlayer架构关系图

二、播放器功能实现

1、画面渲染(TextureView)
1.1、TextureView创建及设置Surface监听
        TextureView textureView =new TextureView(context);
        textureView .setSurfaceTextureListener(this);
1.2、在TextureView初始化完成的onSurfaceTextureAvailable回调里将SurfaceTexture与MediaPlayer绑定
    private MediaTextureView mTextureView;//画面渲染
    private Surface mSurface;
    private SurfaceTexture mSurfaceTexture;

    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) {
//        ILogger.d(TAG,"onSurfaceTextureAvailable-->width:"+width+",height:"+height);
        if(null==mTextureView||null==mMediaPlayer) return;
        if(null!=mSurfaceTexture){
            mTextureView.setSurfaceTexture(mSurfaceTexture);
        }else{
            mSurfaceTexture = surfaceTexture;
            mSurface =new Surface(surfaceTexture);
            mMediaPlayer.setSurface(mSurface);
        }
    }
2、全屏播放
2.1、开启全屏播放
  • 全屏分三个步骤:1、保存播放器父容器ViewGroup。2、改变屏幕方向为横屏。3、将播放器添加到Window中。
    /**
     * 全屏播放
     * @param bgColor 开启全屏模式播放:横屏时播放器的背景颜色,内部默认用黑色#000000
     */
    @Override
    public void startFullScreen(int bgColor) {
//        ILogger.d(TAG,"startFullScreen");
        if(mScreenOrientation==IMediaPlayer.ORIENTATION_LANDSCAPE) return;
        Activity activity = PlayerUtils.getInstance().getActivity(getTargetContext());
        if (null != activity&& !activity.isFinishing()) {
            ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView();
            if(null==viewGroup){
                return;
            }
            //1.保存播放器在父布局中的宽、高、index层级等属性(如果存在的话)
            mPlayerParams = new int[3];
            mPlayerParams[0]=this.getMeasuredWidth();
            mPlayerParams[1]=this.getMeasuredHeight();
            if(null!=getParent()&& getParent() instanceof ViewGroup){
                mParent = (ViewGroup) getParent();
                mPlayerParams[2]=mParent.indexOfChild(this);//保存播放器本身的宽高和位于父容器的索引位置,恢复正常模式时需准确的还原到父容器index
            }
            PlayerUtils.getInstance().removeViewFromParent(this);//从原宿主中移除自己
            //2.改变屏幕方向为横屏状态,播放器所在的Activity需要添加属性:android:configChanges="orientation|screenSize"
            activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);//改变屏幕方向
            setScreenOrientation(IMediaPlayer.ORIENTATION_LANDSCAPE);//更新控制器方向状态
            findViewById(R.id.player_surface).setBackgroundColor(bgColor!=0?bgColor:Color.parseColor("#000000"));//设置一个背景颜色
            //3.隐藏NavigationBar和StatusBar
            hideSystemBar(viewGroup);
            //4.添加到此播放器宿主context的window中
            viewGroup.addView(this, new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER));
        }
    }
2.2、退出全屏播放
  • 退出全屏分四个步骤:1、Window窗口中移除自己。2、改变屏幕方向为竖屏。3、还原全屏设置为正常设置。4、将自己交给此前的宿主ViewGroup(还需要注意:还原播放器在原宿主的宽、高、index位置)

    /**
     * 退出全屏播放
     */
    @Override
    public void quitFullScreen() {
//        ILogger.d(TAG,"quitLandscapeScreen");
        Activity activity = PlayerUtils.getInstance().getActivity(getTargetContext());
        if(null!=activity&&!activity.isFinishing()){
            ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView();
            if(null==viewGroup){
                return;
            }
            //1:从Window窗口中移除自己
            PlayerUtils.getInstance().removeViewFromParent(this);
            //2.改变屏幕方向为竖屏
            activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);//改变屏幕方向
            setScreenOrientation(IMediaPlayer.ORIENTATION_PORTRAIT);
            findViewById(R.id.player_surface).setBackgroundColor(Color.parseColor("#00000000"));//设置纯透明背景
            //3.还原全屏设置为正常设置
            showSysBar(viewGroup);
            //3.将自己交给此前的宿主ViewGroup,并还原播放器在原宿主的宽、高、index位置
            if(null!=mParent){
                if(null!=mPlayerParams&&mPlayerParams.length>0){
//                    ILogger.d(TAG,"index:"+mPlayerParams[2]);
                    mParent.addView(this, mPlayerParams[2],new LayoutParams(mPlayerParams[0], mPlayerParams[1]));//将自己还原到父容器的index位置,取消了Gravity.CENTER属性
                }else{
                    mParent.addView(this, new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
                }
//                ILogger.d(TAG,"quitLandscapeScreen-->已退出全屏");
            }else{
                //通知宿主监听器触发返回事件
//                ILogger.d(TAG,"quitLandscapeScreen-->退出全屏无宿主接收,销毁播放器");
                //无宿主接收时直接停止播放并销毁播放器
                onDestroy();
            }
        }
    }
3、自定义控制器及UI交互组件
3.1、自定义控制器
  • BasePlayer提供了setController(BaseController controller)方法给有需要UI交互的场景设置UI控制器
    /**
     * 设置视图控制器
     * @param controller 继承VideoBaseController的控制器
     */
    @Override
    public void setController(BaseController controller) {}
3.2、自定义UI交互组件
  • 为什么有自定义Controller还整个自定义UI交互组件?因为Controller不适合处理所有UI交互,比如播放器的场景不同,UI也不尽相同,此时若把所有UI交互全写在Controller会显得臃肿、耦合性过高、开发者无法根据自己的需要来选择和自定义部分UI交互。
  • 自定义交互组件的使用
        //播放器的准备
        VideoPlayer videoPlayer = new VideoPlayer(this);
        videoPlayer.setBackgroundColor(Color.parseColor("#000000"));
        VideoController controller=new VideoController(videoPlayer.getContext());
        /**
         * 给播放器设置控制器
         */
        videoPlayer.setController(controller);
        /**
         * 给播放器控制器绑定需要的自定义UI交互组件
         */
        ControlToolBarView toolBarView=new ControlToolBarView(this);//标题栏,返回按钮、视频标题、功能按钮、系统时间、电池电量等组件
        ControlFunctionBarView functionBarView=new ControlFunctionBarView(this);//底部时间、seek、静音、全屏功能栏
        functionBarView.showSoundMute(true,false);//启用静音功能交互\默认不静音
        ControlStatusView statusView=new ControlStatusView(this);//移动网络播放提示、播放失败、试看完成
        ControlGestureView gestureView=new ControlGestureView(this);//手势控制屏幕亮度、系统音量、快进、快退UI交互
        ControlCompletionView completionView=new ControlCompletionView(this);//播放完成、重试
        ControlLoadingView loadingView=new ControlLoadingView(this);//加载中、开始播放
        //将自定义UI交互组件设置到控制器
        controller.addControllerWidget(toolBarView,functionBarView,statusView,gestureView,completionView,loadingView);
4、自定义解码器
  • SDK内部封装时,为了方便开发者切换解码器,将切换解码器的入口封装在播放器的监听器内,开发者可在回调方法返回自己的解码器。
    private int MEDIA_CORE=2;//这里默认用ExoPlayer解码器

        //自定义解码器
        mVideoPlayer.setOnPlayerActionListener(new OnPlayerEventListener() {
            @Override
            public AbstractMediaPlayer createMediaPlayer() {
                if (1 == MEDIA_CORE) {
                    return new JkMediaPlayer(LivePlayerActivity.this);
                } else if (2 == MEDIA_CORE) {
                    return new ExoMediaPlayer(LivePlayerActivity.this);
                } else {
                    return null;
                }
            }
        });
  • 自定义解码器请参考Demo中的JkMediaPlayer和ExoMediaPlayer类。
5、转场无缝衔接播放实现
5.1、列表转场衔接继续播放原理:
    1、点击跳转到新的界面时将播放器从父容器中移除,并保存到全局变量
    2、将全局变量播放器对象添加到新的ViewGroup容器
    3、回到列表界面时如果播放的视频源没有被切换,关闭当前Activity不要销毁播放器,将播放器从当前父容器中移除
    4、重新添加到列表界面的此前正在播放的item中的ViewGroup中
5.2、列表转场衔接继续播放实现:主要参考Demo中的ListPlayerChangedFragmentListPlayerFragmentVideoDetailsActivity
    1、开始播放:参考ListPlayerFragment类的startPlayer()方法,注意标记当前mCurrentPosition和mPlayerContainer
    2、点击item跳转:参考ListPlayerChangedFragment类的onItemClick()方法,跳转到新的Activity
    3、新的Activity接收播放器继续播放:参考VideoDetailsActivity类的initPlayer方法,根据mIsChange变量来确认是否处理转场播放。
    4、新的Activity销毁:新的Activity在关闭时如果播放器视频地址未被切换,则在onDestroy中不要销毁播放器,参考:VideoDetailsActivity类的onDestroy
    5、回到列表界面:如果处理了第4步,在回到列表界面时接收并处理播放器,参考:ListPlayerChangedFragment类的onActivityResult方法和ListPlayerFragment类的recoverPlayerParent方法
6、Window窗口播放实现
    /**
     * 开启Activity级别的小窗口播放
     * @param width 窗口播放器的宽,当小于=0时用默认
     * 开启可拖拽的窗口播放
     * 默认宽为屏幕1/2+30dp,高为1/2+30dp的16:9比例,X起始位置为:播放器原宿主的右下方,距离原宿主View顶部15dp,右边15dp(如果原宿主不存在,则位于屏幕右上角距离顶部60dp位置)
     * 全局悬浮窗口和局部小窗口不能同时开启
     * 横屏下不允许开启
     * @param height 窗口播放器的高,当小于=0时用默认
     * @param startX 窗口位于屏幕中的X轴起始位置,当小于=0时用默认
     * @param startY 窗口位于屏幕中的Y轴起始位置
     * @param radius 窗口的圆角 单位:像素
     * @param bgColor 窗口的背景颜色
     */
    @Override
    public void startWindow(int width, int height, float startX, float startY, float radius, int bgColor) {
        ILogger.d(TAG,"startWindow-->width:"+width+",height:"+height+",startX:"+startX+",startY:"+startY+",radius:"+radius+",bgColor:"+bgColor+",windowProperty:"+ mIsActivityWindow +",screenOrientation:"+mScreenOrientation);
        if(mIsActivityWindow ||mScreenOrientation==IMediaPlayer.ORIENTATION_LANDSCAPE) return;//已开启窗口模式或者横屏情况下不允许开启小窗口
        Activity activity = PlayerUtils.getInstance().getActivity(getTargetContext());
        if (null != activity&& !activity.isFinishing()) {
            ViewGroup viewGroup = (ViewGroup) activity.getWindow().getDecorView();
            if(null==viewGroup){
                return;
            }
            int[] screenLocation=new int[2];
            //保存播放器本身的宽高和位于父容器的索引位置,恢复正常模式时需准确的还原到父容器index
            mPlayerParams = new int[3];
            mPlayerParams[0]=this.getMeasuredWidth();
            mPlayerParams[1]=this.getMeasuredHeight();
            //1.从原有竖屏窗口移除自己前保存自己的Parent,直接开启全屏是不存在宿主ViewGroup的,可直接窗口转场
            if(null!=getParent()&& getParent() instanceof ViewGroup){
                mParent = (ViewGroup) getParent();
                mParent.getLocationInWindow(screenLocation);
                mPlayerParams[2]=mParent.indexOfChild(this);
//                ILogger.d(TAG,"startWindow-->parent_id:"+getId()+",parentX:"+screenLocation[0]+",parentY:"+screenLocation[1]+",parentWidth:"+mParent.getWidth()+",parentHeight:"+mParent.getHeight());
            }
            PlayerUtils.getInstance().removeViewFromParent(this);//从原宿主中移除自己
            //2.改变播放器横屏或窗口播放状态
            setWindowPropertyPlayer(true,false);
            //3.获取宿主的View属性和startX、Y轴
            //如果传入的宽高不存在,则使用默认的16:9的比例创建Window View
            if(width<=0){
                width = PlayerUtils.getInstance().getScreenWidth(getContext())/2+PlayerUtils.getInstance().dpToPxInt(30f);
                height = width*9/16;
//                ILogger.d(TAG,"startWindow-->未传入宽高,width:"+width+",height:"+height);
            }
            //如果传入的startX不存在,则startX起点位于屏幕宽度1/2-距离右侧15dp位置,startY起点位于宿主View的下方15dp处
            if(startX<=0&&null!=mParent){
                startX=(PlayerUtils.getInstance().getScreenWidth(getContext())/2-PlayerUtils.getInstance().dpToPxInt(30f))-PlayerUtils.getInstance().dpToPxInt(15f);
                startY=screenLocation[1]+mParent.getHeight()+PlayerUtils.getInstance().dpToPxInt(15f);
//                ILogger.d(TAG,"startWindow-->未传入X,Y轴,取父容器位置,startX:"+startX+",startY:"+startY);
            }
            //如果宿主也不存在,则startX起点位于屏幕宽度1/2-距离右侧15dp位置,startY起点位于屏幕高度-Window View 高度+15dp位置处
            if(startX<=0){
                startX=(PlayerUtils.getInstance().getScreenWidth(getContext())/2-PlayerUtils.getInstance().dpToPxInt(30f))-PlayerUtils.getInstance().dpToPxInt(15f);
                startY=PlayerUtils.getInstance().dpToPxInt(60f);
//                ILogger.d(TAG,"startWindow-->未传入X,Y轴或取父容器位置失败,startX:"+startX+",startY:"+startY);
            }
            ILogger.d(TAG,"startWindow-->final:width:"+width+",height:"+height+",startX:"+startX+",startY:"+startY);
            //4.转场到window中,并指定宽高和x,y轴
            WindowPlayerFloatView container=new WindowPlayerFloatView(viewGroup.getContext());
            container.setOnWindowActionListener(new OnWindowActionListener() {
                @Override
                public void onMovie(float x, float y) {

                }

                @Override
                public void onClick(BasePlayer basePlayer, Object coustomParams) {

                }

                @Override
                public void onClose() {
//                    ILogger.d(TAG,"startWindow-->onClose");
                    quitWindow();//退出小窗口
                }
            });
            container.setId(R.id.player_window);
            container.addPlayerView(this,width,height,startX,startY,radius,bgColor);//先将播放器包装到可托拽的容器中
            viewGroup.addView(container, new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER));
        }
    }

三、更多功能和全部源码请移步至iPlayer

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容