Android 視頻無縫切換2.0

上一篇文章簡單講解了騰訊新聞的視頻無縫切換效果的實現(xiàn)(視頻在播放中進行頁面切換),如果你沒有看過上篇,可以先去看看Android 高仿騰訊新聞視頻切換效果。
上一篇寫得比較隨意,只是講解了兩個頁面間如何實現(xiàn)視頻在播放中的切換(切換播放器的container)及滾動停止播放等,部分效果沒有實現(xiàn),有一些細節(jié)不是處理得很好,所以重新補上一篇更加詳細的教程。相同的內(nèi)容這次就不在贅述了。
同樣,還是先上效果圖


幀率有稍微的調(diào)整,壓縮得有些掉幀了,感興趣的可以下載看看。
說一下這次新增的效果吧:

  • 進入全屏自動判斷橫豎屏切換方向
  • 全屏播放完畢后自動播放下一個,并且根據(jù)視頻寬高切換方向
  • 播放時增加遮罩層,播放下一個文字提示
  • 播放時增加倒計時及動畫
  • wifi切換4G提示,4G切wifi自動播放

這次播放器換成了JZVideoPlayer,如果項目中還沒有接入播放器或者剛接入的,還是建議換成PlayerBase,高度解耦,可擴展性高,提供無縫續(xù)播助手。

JZVideoPlayer版本是之前的,并且改動有點大。這里主要介紹思路,跟注意點,用PlayerBase同樣也可以實現(xiàn)的。
JZVideoPlayer實現(xiàn)無縫切換其實就是更改player的VIewParent

    public void attachToContainer(ViewGroup container) {
        detachSuperContainer();
        if (container != null) {
            container.addView(JZVideoPlayerManager.getCurrentJzvd(), new FrameLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
            playerContainer = container;
        }
    }

    public void detachSuperContainer() {
        JZVideoPlayer player = JZVideoPlayerManager.getCurrentJzvd();
        ViewParent parent = player.getParent();
        if (parent != null && parent instanceof ViewGroup) {
            ((ViewGroup) parent).removeView(player);
        }
    }

4G跟wifi切換出現(xiàn)提示:注冊一個廣播進行監(jiān)聽

    @Override
    protected void onResume() {
        super.onResume();
        JZVideoPlayer.goOnPlayOnResume();
        IntentFilter filter = new IntentFilter();
        filter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION);
        filter.addAction(WifiManager.WIFI_STATE_CHANGED_ACTION);
        registerReceiver(wifiReceiver, filter);
    }

    @Override
    protected void onStop() {
        super.onStop();
        try {
            //weChat moment share will execute twice so try catch
            unregisterReceiver(wifiReceiver);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    private BroadcastReceiver wifiReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent != null && WifiManager.NETWORK_STATE_CHANGED_ACTION.equals(intent.getAction())) {
                NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
                if (info != null) {
                    if (info.getState().equals(NetworkInfo.State.DISCONNECTED)) {
                        if (JZMediaManager.isWiFi) {
                            JZMediaManager.isWiFi = false;
                            JZVideoPlayer.WIFI_TIP_DIALOG_SHOWED = false;
                           if(播放中或者加載中){
                                 JZMediaManager.instance().jzMediaInterface.pause();
                                JZVideoPlayerManager.getCurrentJzvd().onStatePause();
                           }
                        }
                    } else if (info.getState().equals(NetworkInfo.State.CONNECTED)) {
                        if (!JZMediaManager.isWiFi) {
                            JZMediaManager.isWiFi = true;
                            JZVideoPlayer.WIFI_TIP_DIALOG_SHOWED = true;
                            if (JZVideoPlayerManager.getCurrentJzvd() != null &&
                                    JZVideoPlayerManager.getCurrentJzvd().currentState == JZVideoPlayer.CURRENT_STATE_PAUSE) {
                                JZVideoPlayer.goOnPlayOnResume();
                            }
                        }
                    }
                }
            }
        }
    };

這里放在onResume里面注冊是因為我的項目不止一個頁面有視頻,所以需要在這里監(jiān)聽。這里注意一下,微信分享的時候onStop會調(diào)用2次,所以要try catch。4G切wifi的時候,要注意如果是用戶手動暫停,是不需要自動播放的。

新聞頁面

  • 滑動停止后播放第一個完全可見的視頻
    public static void onScrollPlayVideo(RecyclerView recyclerView, int firstVisiblePosition, int lastVisiblePosition) {
        if (JZMediaManager.isWiFi) {
            for (int i = 0; i <= lastVisiblePosition - firstVisiblePosition; i++) {
                View child = recyclerView.getChildAt(i);
                View view = child.findViewById(R.id.player);
                if (view != null && view instanceof JZVideoPlayerStandard) {
                   JZVideoPlayerStandard player = (JZVideoPlayerStandard) view;
                    if (getViewVisiblePercent(player) == 1f) {
                        if (JZMediaManager.instance().positionInList != i + firstVisiblePosition) {
                            player.startButton.performClick();
                        }
                        break;
                    }
                }
            }
        }
    }

這里使用的是播放中item的position去判斷是否是第一個完全可見的視頻,如果你的item的position會變(別問我為什么,真的會有這種情況,手動狗頭),就要用

JZVideoPlayerManager.getCurrentJzvd() != player

去判斷。
計算view的可見百分比,范圍是0-1

    public static float getViewVisiblePercent(View view) {
        if (view == null) {
            return 0f;
        }
        float height = view.getHeight();
        Rect rect = new Rect();
        if (!view.getLocalVisibleRect(rect)) {
            return 0f;
        }
        float visibleHeight = rect.bottom - rect.top;
        Log.d(TAG, "getViewVisiblePercent: emm " + visibleHeight);
        return visibleHeight / height;
    }
  • 部分滾出屏幕后停止播放
    public static void onScrollReleaseAllVideos(int firstVisiblePosition, int lastVisiblePosition,float percent) {
        int currentPlayPosition = JZMediaManager.instance().positionInList;
        if (currentPlayPosition >= 0) {
            if ((currentPlayPosition <= firstVisiblePosition || currentPlayPosition >= lastVisiblePosition - 1)) {
                if (getViewVisiblePercent(JZVideoPlayerManager.getCurrentJzvd()) < percent) {
                    JZVideoPlayer.releaseAllVideos();
                }
            }
        }
    }
  • 無縫切換
    上個版本的切換代碼有點小小的瑕疵(Y坐標沒有算對),所以這里還是貼一下移動的代碼,留意一下這里的translationY的值
//第一版
holder.itemView.setTranslationY(attr.getY() - l[1]);
 holder.container.setScaleX(attr.getWidth() / (float) holder.container.getMeasuredWidth());
                    holder.container.setScaleY(attr.getHeight() / (float) holder.container.getMeasuredHeight());
//修改版
 holder.itemView.setTranslationY(attr.getY() - l[1] - (holder.container.getMeasuredHeight() - attr.getHeight()) / 2);
 holder.container.setScaleX(attr.getWidth() / (float) holder.container.getMeasuredWidth());
                    holder.container.setScaleY(attr.getHeight() / (float) holder.container.getMeasuredHeight());

如果容器大小相同(視頻列表頁進入評論頁),那直接用坐標相減就行,但這里對播放器的大小進行了改變,就需要減去高度差的一半,這里還要除以2是因為縮放的中心是view的中點。

這里由于用的JZVideoPlayer,需要固定播放容器的寬高,不然會觸發(fā)view的onMeasure導(dǎo)致閃爍

效果圖
看著還可以吧

視頻列表頁面

進入這個頁面的時候需要分直接進入和視頻播放進入兩種情況。直接進入,就是直接添加fragment,再播放第一個視頻,視頻播放進入就是無縫切換效果。退出頁面同理

PS:無縫切換的時候要留意一下,在新聞頁,點擊是直接進入視頻列表,而在視頻列表這里,點擊是出現(xiàn)控制器的。在新聞頁有個倒計時動畫,而在視頻列表頁是沒有的。這些在頁面切換的時候,都需要進行對應(yīng)的顯示隱藏和點擊事件的設(shè)置等等。

  • 滑動停止后播放第一個完全可見視頻
    代碼跟上面的差不多,不再贅述,詳見demo
    這里留意一下視頻滑出區(qū)域后回收監(jiān)聽。
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                if (dy != 0) {
                    JZUtils.onScrollReleaseAllVideos(mLayoutManager.findFirstVisibleItemPosition(), mLayoutManager.findLastVisibleItemPosition(), 0.2f);
                }
            }

這里的onScrolled()方法有個小小的坑(個人感覺)。


能什么坑,我一直都是這么用的呀??隙ㄓ腥诉@么想吧。
還是來看看這個方法的注釋吧

/**
         * Callback method to be invoked when the RecyclerView has been scrolled. This will be
         * called after the scroll has completed.
         * <p>
         * This callback will also be called if visible item range changes after a layout
         * calculation. In that case, dx and dy will be 0.
         *
         * @param recyclerView The RecyclerView which scrolled.
         * @param dx The amount of horizontal scroll.
         * @param dy The amount of vertical scroll.
         */
        public void onScrolled(RecyclerView recyclerView, int dx, int dy){}
    }

當recyclerView滑動后,這個方法就會被回調(diào)。很正常對吧??墒窍旅孢€有兩行呢。當可見item重新測量,布局后,也會觸發(fā)這個方法,此時dx,dy都是0。這里要注意的就是我們這里是有全屏功能的,而且還會切換橫豎屏,那就會觸發(fā)這個方法。導(dǎo)致功能不正常了。所以上面加了個不為0的判斷。

  • 播放完畢自動播放下一個視頻
    這里需要留意一下,播放下一個視頻我是通過滑動下一個視頻到頂部從而觸發(fā)播放的??墒且矔羞@種情況,就是你的視頻特別少(我們的app就是),那就無法播放最后一個視頻了。騰訊的數(shù)據(jù)量夠大,一般不會有這個問題。所以當沒有更多的時候,需要在recyclerView的底部插入一條數(shù)據(jù),顯示沒有更多數(shù)據(jù),就可以播放這個視頻了,騰訊也是這么處理的,看得出來設(shè)計得很周全,一個頁面只能完全顯示一個視頻,考慮得十分全面啊。


  • 遮罩
    遮罩這里用的是自定義view,畫一個半通明的背景。
    當列表播放時,顯示遮罩,并且需要一個過渡的效果(透明度動畫)。
    當滑動界面,顯示評論頁,切換全屏,退出視頻列表時,隱藏遮罩,不需要過渡效果。

  • 播放下一條提示
    通過播放器監(jiān)聽視頻剩余時間,當剩余時間小于5s時,顯示提示。播放下一條或滑動頁面,提示消失。點擊提示,則播放下一條視頻(全屏狀態(tài)下不顯示該提示)。

  • 進入全屏自動判斷橫豎屏切換方向
    主要通過播放器的onVideoSizeChanged()方法進行監(jiān)聽。

        if (JZMediaManager.instance().currentVideoWidth > JZMediaManager.instance().currentVideoHeight) {
            JZVideoPlayer.FULLSCREEN_ORIENTATION = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
            if(JZVideoPlayerManager.getCurrentJzvd().currentScreen == SCREEN_WINDOW_FULLSCREEN){
                JZUtils.setRequestedOrientation(JZVideoPlayerManager.getCurrentJzvd().getContext(), ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
            }
        } else {
            JZVideoPlayer.FULLSCREEN_ORIENTATION = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
            if(JZVideoPlayerManager.getCurrentJzvd().currentScreen == SCREEN_WINDOW_FULLSCREEN){
                JZUtils.setRequestedOrientation(JZVideoPlayerManager.getCurrentJzvd().getContext(), ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
            }
        }

根據(jù)寬高設(shè)置進入全屏是豎屏還是橫屏(如果你們公司非主流,不能根據(jù)視頻寬高判斷,那就后臺加個字段設(shè)置吧)。


android P這里有個bug,切換屏幕方向的時候會黑屏,暫時未發(fā)現(xiàn)解決辦法,知道怎么解決的大佬歡迎下方留言?。。?!另外,部分國產(chǎn)手機seekbar點擊之后不是直接跳到對應(yīng)的進度,而是快進一點點,也是服了呀...

  • 全屏播放完畢后,切換url播放下一條,根據(jù)視頻寬高和當前方向判斷是否需要切換屏幕方向,并且滑動列表
    切換url
    public void changeUrl(String url, Object... objects) {
        this.currentUrlMapIndex = 0;
        this.seekToInAdvance = 0;
        LinkedHashMap map = new LinkedHashMap();
        map.put(URL_KEY_DEFAULT, url);
        Object[] dataSourceObjects = new Object[1];
        dataSourceObjects[0] = map;
        this.dataSourceObjects = dataSourceObjects;
        this.objects = objects;
        setState(CURRENT_STATE_PREPARING_CHANGING_URL);
        resetProgressAndTime();
    }

主要就是重置一些狀態(tài),改變變量的值。
判斷方向
同樣也會觸發(fā)onVideoSizeChanged()方法,在里面進行判斷就好了,其實就是上面那段代碼啦。

PS:切換url的時候最好把畫面渲染層隱藏起來,播放的時候再顯示。不然的話部分機器可能會出現(xiàn)最后一幀的畫面被拉伸的情況。

列表滑動
這里需要注意一下,我們上面對滑動進行了監(jiān)聽,不能調(diào)用smoothScrollTo()或者smoothScrollBy()方法。這里可以直接調(diào)用scrollToPositionWithOffset(),直接滑動到對應(yīng)位置(如果你不是LinearLayoutManager,那就自己想辦法吧。)

如果你跟我一樣,都是用的JZVideoPlayer,那下面就要留意一下啦


重點來了

JZVideoPlayer全屏跟非全屏用的是2個播放器,所以全屏的時候要做好狀態(tài)跟接口的同步,并且退出全屏的時候,如果url不一樣,就不能繼續(xù)播放了。所以在滑動完畢后,需要更改第一個播放器。部分代碼如下:

if (JZVideoPlayerManager.getCurrentJzvd().currentScreen == SCREEN_WINDOW_FULLSCREEN) {
                JZMediaManager.instance().positionInList++;
                JZVideoPlayerManager.getCurrentJzvd().changeUrl(mList.get(JZMediaManager.instance().positionInList).getVideoUrl());
                mLayoutManager.scrollToPositionWithOffset(JZMediaManager.instance().positionInList, 0);
                mRecycler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        JZVideoPlayerManager.setFirstFloor((JZVideoPlayer) mRecycler.getChildAt(0).findViewById(R.id.player));
                    }
                }, 500);
            }

進入和退出視頻列表頁進行無縫播放時,對播放器的父view進行了更改,也就會需要進行addView或者removeView,并且修改相關(guān)接口等等操作。



退出的邏輯比較復(fù)雜,來看看退出視頻列表的處理的偽代碼,進入的代碼在VideoListAdapter的onBindViewHolder里,可對照上篇博客自行查看。

 if (還在播放第一個視頻) {
            videoListFragment.removeVideoList();
            recycler.postDelayed(new Runnable() {
                @Override
                public void run() {
                    JZMediaManager.instance().positionInList = clickPosition;
                    int first = mLayoutManager.findFirstVisibleItemPosition();
                    View v = recycler.getChildAt(clickPosition - first);
                    if (v != null) {
                        final PlayerContainer container = v.findViewById(R.id.adapter_video_container);
                        if (不是無縫播放進入視頻列表頁) {
                            container.removeAllViews();
                        }
                      //播放器接口,狀態(tài)設(shè)置
                    }
                    FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
                    transaction.remove(videoListFragment);
                    transaction.commitAllowingStateLoss();
                }
            }, 800);
        } else {
            JZVideoPlayer.releaseAllVideos();
            FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
            transaction.remove(videoListFragment);
            transaction.commitAllowingStateLoss();
            if (是無縫播放進入視頻列表頁) {
                int first = mLayoutManager.findFirstVisibleItemPosition();
                View v = recycler.getChildAt(clickPosition - first);
                if (v != null) {
                    final PlayerContainer container = v.findViewById(R.id.adapter_video_container);
                    container.removeAllViews();
                    //重新添加播放器
                }
            }
        }

還是解釋一下吧,這里分4種情況:

  • 進入和退出都是無縫播放,直接修改player的viewParent即可
  • 進入和退出都不是無縫播放,不需要處理
  • 進入是無縫播放,退出不是。也就是進入頁面時移動了播放器,但退出時沒有移回去。就需要在原來的位置添加一個一樣的播放器
  • 進入不是無縫播放,退出是。也就是退出的時候把播放器移到新聞頁了,但原本新聞頁時有一個播放器的。那就需要把原來的播放器移除,然后對移動的播放器進行接口,狀態(tài)改變。

邏輯確實復(fù)雜,需要多看幾遍


如果還沒接入播放器,還是用PlayerBase吧。

部分效果

評論頁

評論頁跟上一次沒有大的區(qū)別,做了一點小小的改動:視頻播放完畢后,會重置為普通狀態(tài),退出評論頁返回視頻列表頁,會自動播放下一條。代碼就不貼了,詳見demo。
大功告成,喝杯82年雪碧慶祝一下吧。



下面是關(guān)于動態(tài)加載ijkplayer so文件的,不需要的可以跳過
動態(tài)加載so目前只見到這2種方案:

  • 通過反射修改libPath,增加一個加載路徑
  • 使用System.load(String filename)代替System.loadLibrary(String libname)
    這里我選了第二種,對第一種感興趣的可以到網(wǎng)上搜一搜,試一下。為了方便,demo中只是動態(tài)加載了其中一個so。
    簡單說一下實現(xiàn)步驟:使用IntentService檢測so文件是否存在且完整(md5),是則切換ijk。否則請求接口(需傳遞支持的aibs)下載文件(aibs,斷點續(xù)傳,md5校檢請自行處理,demo均沒有實現(xiàn)),下載完畢后切換。注意一下下載的路徑
 File dir = getDir("libs", Context.MODE_PRIVATE);
 File soFile = new File(dir, "ijkffmpeg.so");

是soFile的路徑。
然后就是加載so庫,剛開始我以為直接把IjkMediaPlayer.Java拷出來修改加載路徑就大功告成,可是卻還是報錯,找不到方法。查了下,發(fā)現(xiàn)JNI的方法名是需要“包名+類名+方法名”,而我這里直接拷過來,包名變了,也就找不到方法了。所以 需要把ijk整個庫拷下來,引入到項目里再進行修改(也可以修改so庫中的包名)。
PS:如果你擔心還是找不到so,可以這樣做

try {
        jzMediaInterface.prepare();
 } catch (Throwable e) {
      e.printStackTrace();
      Object dataSource = JZMediaManager.getCurrentDataSource();
      Log.e(TAG, "handleMessage: " + e.getMessage());
      Toast.makeText(MyApplication.getInstance(), "so error", Toast.LENGTH_SHORT).show();
      JZVideoPlayer.setMediaInterface(new JZExoPlayer());
       jzMediaInterface.currentDataSource = dataSource;
       jzMediaInterface.prepare();
    }

捕獲初始化錯誤,再切換回備用內(nèi)核。

拖了好久終于把這個東西寫完了,高難度的東西沒多少,全都是細節(jié)的處理。雖然效果還可以,但還是逃不了上次說的問題,不能在activity間切換,邏輯復(fù)雜,耦合度太高。


無奈

順便吐槽幾句,一分錢一分貨


一分錢一分貨

好的產(chǎn)品是打磨出來的,不是趕出來的,希望某些人心中有點AC數(shù)

最后,附上源碼,有問題或者有更好的實現(xiàn)方式,歡迎下方留言,有空看到會回復(fù)的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容