Android 使用ExoPlayer视频播放 (二)

一、缓存

1、使用ExoPlayer自带的缓存机制(匹配完整的url地址,相同则使用本地缓存文件播放,视频地址具有时效性参数时无法正确缓存)

创建缓存文件夹

public class CachesUtil {

     public static String VIDEO = "video";

    /**
     * 获取媒体缓存文件
     *
     * @param child
     * @return
     */
    public static File getMediaCacheFile(String child) {
        String directoryPath = "";
        if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            // 外部储存可用
            directoryPath = MyApplication.getContext().getExternalFilesDir(child).getAbsolutePath();
        } else {
            directoryPath = MyApplication.getContext().getFilesDir().getAbsolutePath() + File.separator + child;
        }
        File file = new File(directoryPath);
        //判断文件目录是否存在
        if (!file.exists()) {
            file.mkdirs();
        }
        LogUtil.d(TAG, "getMediaCacheFile ====> " + directoryPath);
        return file;
    }
}

创建带缓存的数据解析工厂

// 测量播放带宽,如果不需要可以传null
TransferListener super DataSource> listener = new DefaultBandwidthMeter();
DefaultDataSourceFactory upstreamFactory = new DefaultDataSourceFactory(this, listener, new DefaultHttpDataSourceFactory("MyApplication", listener));
// 获取缓存文件夹
File file = CachesUtil.getMediaCacheFile(CachesUtil.VIDEO);
Cache cache = new SimpleCache(file, new NoOpCacheEvictor());
// CacheDataSinkFactory 第二个参数为单个缓存文件大小,如果需要缓存的文件大小超过此限制,则会分片缓存,不影响播放
DataSink.Factory cacheWriteDataSinkFactory = new CacheDataSinkFactory(cache, Long.MAX_VALUE);
CacheDataSourceFactory dataSourceFactory = new CacheDataSourceFactory(cache, upstreamFactory, new FileDataSourceFactory(), cacheWriteDataSinkFactory, CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, null);

使用带缓存的数据解析工厂创建资源,和入门的使用一致

 Uri uri = Uri.parse(url);
ExtractorMediaSource mediaSource = new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
player.prepare(mediaSource);
player.setPlayWhenReady(true);

2、使用第三方库AndroidVideoCache进行缓存(视频地址具有时效性参数时使用此缓存方式)

添加AndroidVideoCache依赖

dependencies {
    implementation'com.danikula:videocache:2.7.0'
}

自定义缓存文件命名规则

public class CacheFileNameGenerator implements FileNameGenerator {

    private static final String TAG = "CacheFileNameGenerator";

    /**
     * @param url
     * @return
     */
    @Override
    public String generate(String url) {
        Uri uri = Uri.parse(url);
        List pathSegList = uri.getPathSegments();
        String path = null;
        if (pathSegList != null && pathSegList.size() > 0) {
            path = pathSegList.get(pathSegList.size() - 1);
        } else {
            path = url;
        }
        Log.d(TAG, "generate return " + path);
        return path;
    }
}

创建单例的AndroidVideoCache实例的方法

public class HttpProxyCacheUtil {

    private static HttpProxyCacheServer videoProxy;

    public static HttpProxyCacheServer getVideoProxy() {
        if (videoProxy == null) {
            videoProxy = new HttpProxyCacheServer.Builder(MyApplication.getContext())
                    .cacheDirectory(CachesUtil.getMediaCacheFile(CachesUtil.VIDEO))
                    .maxCacheSize(1024 * 1024 * 1024) // 缓存大小
                    .fileNameGenerator(new CacheFileNameGenerator())
                    .build();
        }
        return videoProxy;
    }
}

使用AndroidVideoCache进行缓存

HttpProxyCacheServer proxy = HttpProxyCacheUtil.getVideoProxy();
// 将url传入,AndroidVideoCache判断是否使用缓存文件
url = proxy.getProxyUrl(url);
// 创建资源,准备播放
Uri uri = Uri.parse(url);
ExtractorMediaSource mediaSource = new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(uri);
player.prepare(mediaSource);
player.setPlayWhenReady(true);

二、自定义播放界面

1、初级自定义

  • 自定义PlaybackControlView播放控制界面
    新建一个XML布局文件exo_playback_control_view,在这个布局文件里面设计我们想要的布局样式,在SimpleExoPlayerView控件中添加一个:
    app:controller_layout_id=”布局id”
    属性。来表明该SimpleExoPlayerView所对应的PlaybackControlView的布局。

这里要注意几个问题:

控件的id不能随便起,这些id都是定义好的,要与exoPlayer原来PlaybackControlView的布局控件id,名称一致,可通过源码查看具体有哪些id。现在给出部分id如下:

<item name="exo_play" type="id"/>
<item name="exo_pause " type="id"/>
<item name="exo_rew " type="id"/>
<item name="exo_ffwd" type="id"/>
<item name="exo_prev" type="id"/>
<item name="exo_next" type="id"/>
<item name="exo_repeat_toggle " type="id"/>
<item name="exo_duration " type="id"/>
<item name="exo_position " type="id"/>
<item name="exo_progress  " type="id"/>

布局的控件数量可以少(比如上一个,下一个这个功能我不想要,就可以不写,也就不会展示出来),但不能多,也不能出现没有定义的id。比如说:想在控制布局上添加一个展开全屏的按钮,那就实现不了
*DefaultTimeBar默认进度条
可以通过xml设置他的颜色,高度,大小等等

app:bar_height="2dp"
app:buffered_color="#ffffff"
app:played_color="#c15d3e"
app:scrubber_color="#ffffff"
app:scrubber_enabled_size="10dp"
app:unplayed_color="#cdcdcd"

2、高级自定义

当我们需要添加更多按钮,比如全屏按钮时,初级自定义就没办法满足我们的需求,这是需要我们自定义重写SimpleExoPlayerView和PlaybackControlView这两个类。这里以添加全屏按钮为例。
* 自定义PlaybackControlView,添加全屏按钮,点击切换横屏
* 自定义SimpleExoPlayerView,使用自定义PlaybackControlView
* 切换横屏时隐藏其他布局,只显示视频控件,达到全屏效果
复制PlaybackControlView代码,新建ExoVideoPlayBackControlView为我们自定义视频控制类,复制SimpleExoPlayerView代码,新建ExoVideoPlayView为我们自定义视频播放控件,将其中使用的控制器换成ExoVideoPlayBackControlView。为ExoVideoPlayBackControlView新建XML文件view_exo_video_play_back_control,添加全屏按钮,再添加全屏播放时的标题栏布局和控制布局,具体界面按需求实现,并将他们隐藏,在全屏播放时在显示。这里全屏按钮的id不在默认定义的id列表中,所以使用”@+id/”自己定义

        "@+id/exo_fill"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="10dp"
            android:layout_marginRight="10dp"
            android:background="@null"
            android:padding="5dp"
            android:scaleType="centerInside"
            android:src="@drawable/selector_video_fill" />

在构造方法中初始我们的布局和控件,给全屏按钮设置点击事件,点击时横屏,调整界面达成全屏的效果

public class ExoVideoPlayBackControlView extends FrameLayout {

    static {
        ExoPlayerLibraryInfo.registerModule("goog.exo.ui");
    } 

    ...

    private final ComponentListener componentListener;// 事件监听
    private final View fillButton; //全屏按钮
    private final View exoPlayerControllerBottom; // 默认控制器
    private final View exoPlayerControllerTopLandscape; // 全屏标题
    private final View exoPlayerControllerBottomLandscape; // 全屏控制器

    ...

    public ExoVideoPlayBackControlView(Context context, AttributeSet attrs, int defStyleAttr,AttributeSet playbackAttrs) {
        super(context, attrs, defStyleAttr);
        int controllerLayoutId = R.layout.view_exo_video_play_back_control;
        componentListener = new ComponentListener();

        ... 

        fillButton = findViewById(R.id.exo_fill);
        if (fillButton != null) {
            fillButton.setOnClickListener(componentListener);
        }
        exoPlayerControllerBottom = findViewById(R.id.exoPlayerControllerBottom);
        exoPlayerControllerTopLandscape = findViewById(R.id.exoPlayerControllerTopLandscape);
        exoPlayerControllerBottomLandscape = findViewById(R.id.exoPlayerControllerBottomLandscape);

    }

    ...

    private final class ComponentListener extends Player.DefaultEventListener implements TimeBar.OnScrubListener, OnClickListener {

    ...

    @Override
    public void onClick(View view) {
        if (player != null) {
            if (fillButton == view) {
                // 设置横屏
                changeOrientation(SENSOR_LANDSCAPE);
            }
        }

    ...

    }
}

在ExoVideoPlayBackControlView切换横竖屏的方法中执行横竖屏切换回调,重新设置是否竖屏参数,修改状态栏属性,在显示和隐藏控制器视图的方法中也要修改状态栏属性

    private synchronized void changeOrientation(@OnOrientationChangedListener.SensorOrientationType int orientation) {

        if (orientationListener == null) {
            return;
        }
        // 执行回调
        orientationListener.onOrientationChanged(orientation);

        switch (orientation) {
            case SENSOR_PORTRAIT:
                // 竖屏
                setPortrait(true);
                showSystemStatusUi();
                break;
            case SENSOR_LANDSCAPE:
                // 横屏
                setPortrait(false);
                showSystemStatusUi();
                break;
            case SENSOR_UNKNOWN:
            default:
                break;
        }
    }

        /**
     * Shows the playback controls. If {@link #getShowTimeoutMs()} is positive then the controls will
     * be automatically hidden after this duration of time has elapsed without user input.
     */
    public void show() {
        if (!isVisible()) {
            setVisibility(VISIBLE);
            // 显示状态栏
            showSystemStatusUi();
            if (visibilityListener != null) {
                visibilityListener.onVisibilityChange(getVisibility());
            }
            updateAll();
            requestPlayPauseFocus();
        }
        // Call hideAfterTimeout even if already visible to reset the timeout.
        hideAfterTimeout();
    }

    /**
     * Hides the controller.
     */
    public void hide() {
        if (isVisible()) {
            setVisibility(GONE);
            if (visibilityListener != null) {
                visibilityListener.onVisibilityChange(getVisibility());
            }
            removeCallbacks(updateProgressAction);
            removeCallbacks(hideAction);
            hideAtMs = C.TIME_UNSET;
            // 收起状态栏,全屏播放
            hideSystemStatusUi();
        }
    }

    public void setPortrait(boolean portrait) {
        this.portrait = portrait;
        // 根据横竖屏情况显示控制器视图
        showControllerByDisplayMode();
    }

    /**
     * 在切换横竖屏时和显示控制器视图显示状态栏
     */
    private void showSystemStatusUi() {
        if (videoViewAccessor == null) {
            return;
        }
        int flag = View.SYSTEM_UI_FLAG_VISIBLE;
        videoViewAccessor.attachVideoView().setSystemUiVisibility(flag);
    }

    /**
     * 隐藏控制器视图时收起状态栏,全屏播放
     */
    private void hideSystemStatusUi() {
        if (portrait) {
            return;
        }
        if (videoViewAccessor == null) {
            return;
        }
        WindowManager windowManager = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
        if (windowManager == null) {
            return;
        }

        int flag = View.SYSTEM_UI_FLAG_LOW_PROFILE
                | View.SYSTEM_UI_FLAG_FULLSCREEN
                | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            flag |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
        }

        videoViewAccessor.attachVideoView().setSystemUiVisibility(flag);
    }

    /**
     * 横屏时设置横屏顶部标题和横屏底部控制器可见,竖屏时设置竖屏底部控制器可见
     */
    private void showControllerByDisplayMode() {
        if (exoPlayerControllerTopLandscape != null) {
            if (portrait) {
                exoPlayerControllerTopLandscape.setVisibility(INVISIBLE);
            } else {
                exoPlayerControllerTopLandscape.setVisibility(VISIBLE);
            }
        }
        if (exoPlayerControllerBottom != null) {
            if (portrait) {
                exoPlayerControllerBottom.setVisibility(VISIBLE);
            } else {
                exoPlayerControllerBottom.setVisibility(INVISIBLE);
            }
        }
        if (exoPlayerControllerBottomLandscape != null) {
            if (portrait) {
                exoPlayerControllerBottomLandscape.setVisibility(INVISIBLE);
            } else {
                exoPlayerControllerBottomLandscape.setVisibility(VISIBLE);
            }
        }
    }

自定义切换横竖屏监听,在activity中定义回调,并逐层传递activity -> ExoVideoPlayView -> ExoVideoPlayBackControlView,在回调中隐藏除了视频播放空间之外的控件,设置Window的flag,在隐藏显示状态栏时不改变原有布局

public interface OnOrientationChangedListener {
    int SENSOR_UNKNOWN = -1;
    int SENSOR_PORTRAIT = SENSOR_UNKNOWN + 1;
    int SENSOR_LANDSCAPE = SENSOR_PORTRAIT + 1;

    @IntDef({SENSOR_UNKNOWN, SENSOR_PORTRAIT, SENSOR_LANDSCAPE})
    @Retention(RetentionPolicy.SOURCE)
    @interface SensorOrientationType {

    }

    void onChanged(@SensorOrientationType int orientation);
}
    evpvAlbumPlay.setOrientationListener(new ExoVideoPlayBackControlView.OrientationListener() {
        @Override
        public void onOrientationChanged(int orientation) {
            if (orientation == SENSOR_PORTRAIT) {
                changeToPortrait();
            } else if (orientation == SENSOR_LANDSCAPE) {
                changeToLandscape();
            }
         }
    });

    private void changeToPortrait() {
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT);
        WindowManager.LayoutParams attr = getWindow().getAttributes();
        Window window = getWindow();
        window.setAttributes(attr);
        window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        rlTitle.setVisibility(View.VISIBLE);
        llOthersAlbumPlay.setVisibility(View.VISIBLE);
    }

    private void changeToLandscape() {
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
        WindowManager.LayoutParams lp = getWindow().getAttributes();
        Window window = getWindow();
        window.setAttributes(lp);
        // 隐藏显示状态栏时不改变原有布局
        window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        rlTitle.setVisibility(View.GONE);
        llOthersAlbumPlay.setVisibility(View.GONE);
    }

重写ExoVideoPlayBackControlView的onKeyDown方法,在全屏模式下点击回退按钮,应切换回竖屏,竖屏时执行回退的回调

public class ExoVideoPlayBackControlView extends FrameLayout {

    public interface ExoClickListener {

        boolean onBackClick(@Nullable View view, boolean isPortrait);

    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
            if (portrait) {
                if (exoClickListener != null) {
                    exoClickListener.onBackClick(null, portrait);
                }
            } else {
                changeOrientation(SENSOR_PORTRAIT);
                return true;
            }
        }
        return super.onKeyDown(keyCode, event);
    }
}
    evpvAlbumPlay.setBackListener(new ExoVideoPlayBackControlView.ExoClickListener() {
        @Override
        public boolean onBackClick(@Nullable View view, boolean isPortrait) {
            if (isPortrait) {
                finish();
            }
            return false;
        }

至此,自定义ExoPlayer,点击全屏播放的功能基本完成,不过还有一些需要完善的地方,比如在全屏播放时显示控制器视图,上边的部分视图会被状态栏挡住,如果手机有虚拟导航栏,导航栏会遮住右边部分视图,所以还需要获取状态高度和虚拟导航栏高度,设置间距

        int navigationHeight = ScreenUtil.getNavigationHeight(context);
        exoPlayerControllerBottom = findViewById(R.id.exoPlayerControllerBottom);
        exoPlayerControllerTopLandscape = findViewById(R.id.exoPlayerControllerTopLandscape);
        exoPlayerControllerTopLandscape.setPadding(0, ScreenUtil.getStatusHeight(context), navigationHeight, 0);
        exoPlayerControllerBottomLandscape = findViewById(R.id.exoPlayerControllerBottomLandscape);
        View llControllerBottomLandscape = findViewById(R.id.llControllerBottomLandscape);
        llControllerBottomLandscape.setPadding(0, 0, navigationHeight, 0);
        timeBarLandscape.setPadding(0, 0, navigationHeight, 0);
public class ScreenUtil {
private ScreenUtil() {
    private ScreenUtil() {
        /* cannot be instantiated */
        throw new UnsupportedOperationException("cannot be instantiated");
    }

    /**
     * 获得状态栏的高度
     *
     * @param context
     * @return
     */
    public static int getStatusHeight(Context context) {

        int statusHeight = -1;
        try {
            Class clazz = Class.forName("com.android.internal.R$dimen");
            Object object = clazz.newInstance();
            int height = Integer.parseInt(clazz.getField("status_bar_height")
                    .get(object).toString());
            statusHeight = context.getResources().getDimensionPixelSize(height);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return statusHeight;
    }

    /**
     * 获得NavigationHeight
     *
     * @param context
     * @return
     */
    public static int getNavigationHeight(Context context) {
        int navigationHeight = 0;
        // 屏幕原始尺寸高度,包括虚拟功能键高度
        int screenHeight = 0;
        // 获取屏幕尺寸,不包括虚拟功能高度
        int defaultDisplayHeight = 0;
        WindowManager windowManager = (WindowManager) context
                .getSystemService(Context.WINDOW_SERVICE);
        Display display = windowManager.getDefaultDisplay();
        DisplayMetrics dm = new DisplayMetrics();
        @SuppressWarnings("rawtypes")
        Class c;
        try {
            c = Class.forName("android.view.Display");
            @SuppressWarnings("unchecked")
            Method method = c.getMethod("getRealMetrics", DisplayMetrics.class);
            method.invoke(display, dm);
            screenHeight = dm.heightPixels;
        } catch (Exception e) {
            e.printStackTrace();
        }

        Point outSize = new Point();
        windowManager.getDefaultDisplay().getSize(outSize);
        defaultDisplayHeight = outSize.y;

        navigationHeight = screenHeight - defaultDisplayHeight;
        return navigationHeight;
    }

}

三、事件监听

ExoPlayer的事件监听EventListener,通过Player的addListener方法和removeListener方法添加和删除。

public interface Player {

  /**
   * Listener of changes in player state.
   */
  interface EventListener {

    /**
     * Called when the timeline and/or manifest has been refreshed.
     * 

* Note that if the timeline has changed then a position discontinuity may also have occurred. * For example, the current period index may have changed as a result of periods being added or * removed from the timeline. This will not be reported via a separate call to * {@link #onPositionDiscontinuity(int)}. * * @param timeline The latest timeline. Never null, but may be empty. * @param manifest The latest manifest. May be null. */ void onTimelineChanged(Timeline timeline, Object manifest); /** * Called when the available or selected tracks change. * * @param trackGroups The available tracks. Never null, but may be of length zero. * @param trackSelections The track selections for each renderer. Never null and always of * length {@link #getRendererCount()}, but may contain null elements. */ void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections); /** * Called when the player starts or stops loading the source. * * @param isLoading Whether the source is currently being loaded. */ void onLoadingChanged(boolean isLoading); /** * Called when the value returned from either {@link #getPlayWhenReady()} or * {@link #getPlaybackState()} changes. * * @param playWhenReady Whether playback will proceed when ready. * @param playbackState One of the {@code STATE} constants. */ void onPlayerStateChanged(boolean playWhenReady, int playbackState); /** * Called when the value of {@link #getRepeatMode()} changes. * * @param repeatMode The {@link RepeatMode} used for playback. */ void onRepeatModeChanged(@RepeatMode int repeatMode); /** * Called when the value of {@link #getShuffleModeEnabled()} changes. * * @param shuffleModeEnabled Whether shuffling of windows is enabled. */ void onShuffleModeEnabledChanged(boolean shuffleModeEnabled); /** * Called when an error occurs. The playback state will transition to {@link #STATE_IDLE} * immediately after this method is called. The player instance can still be used, and * {@link #release()} must still be called on the player should it no longer be required. * * @param error The error. */ void onPlayerError(ExoPlaybackException error); /** * Called when a position discontinuity occurs without a change to the timeline. A position * discontinuity occurs when the current window or period index changes (as a result of playback * transitioning from one period in the timeline to the next), or when the playback position * jumps within the period currently being played (as a result of a seek being performed, or * when the source introduces a discontinuity internally). *

* When a position discontinuity occurs as a result of a change to the timeline this method is * not called. {@link #onTimelineChanged(Timeline, Object)} is called in this case. * * @param reason The {@link DiscontinuityReason} responsible for the discontinuity. */ void onPositionDiscontinuity(@DiscontinuityReason int reason); /** * Called when the current playback parameters change. The playback parameters may change due to * a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player itself may change * them (for example, if audio playback switches to passthrough mode, where speed adjustment is * no longer possible). * * @param playbackParameters The playback parameters. */ void onPlaybackParametersChanged(PlaybackParameters playbackParameters); /** * Called when all pending seek requests have been processed by the player. This is guaranteed * to happen after any necessary changes to the player state were reported to * {@link #onPlayerStateChanged(boolean, int)}. */ void onSeekProcessed(); } }

其中onPlayerStateChanged方法返回了是否正在播放和播放状态,播放状态一共以下几种:

public interface Player {
  /**
   * The player does not have any media to play.
   */
  int STATE_IDLE = 1;
  /**
   * The player is not able to immediately play from its current position. This state typically
   * occurs when more data needs to be loaded.
   */
  int STATE_BUFFERING = 2;
  /**
   * The player is able to immediately play from its current position. The player will be playing if
   * {@link #getPlayWhenReady()} is true, and paused otherwise.
   */
  int STATE_READY = 3;
  /**
   * The player has finished playing the media.
   */
  int STATE_ENDED = 4;
}

具体使用可参考SimpleExoPlayerView和PlaybackControlView,这两个类中的ComponentListener类实现了这个事件监听。

你可能感兴趣的:(Android)