创建缓存文件夹
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);
添加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<String> 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);
- 自定义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"
当我们需要添加更多按钮,比如全屏按钮时,初级自定义就没办法满足我们的需求,这是需要我们自定义重写SimpleExoPlayerView和PlaybackControlView这两个类。这里以添加全屏按钮为例。
* 自定义PlaybackControlView,添加全屏按钮,点击切换横屏
* 自定义SimpleExoPlayerView,使用自定义PlaybackControlView
* 切换横屏时隐藏其他布局,只显示视频控件,达到全屏效果
复制PlaybackControlView代码,新建ExoVideoPlayBackControlView为我们自定义视频控制类,复制SimpleExoPlayerView代码,新建ExoVideoPlayView为我们自定义视频播放控件,将其中使用的控制器换成ExoVideoPlayBackControlView。为ExoVideoPlayBackControlView新建XML文件view_exo_video_play_back_control,添加全屏按钮,再添加全屏播放时的标题栏布局和控制布局,具体界面按需求实现,并将他们隐藏,在全屏播放时在显示。这里全屏按钮的id不在默认定义的id列表中,所以使用”@+id/”自己定义
<ImageButton android: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. * <p> * 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 <em>not</em> 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). * <p> * When a position discontinuity occurs as a result of a change to the timeline this method is * <em>not</em> 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类实现了这个事件监听。