联系我们
简单又实用的WordPress网站制作教学
当前位置:网站首页 > 程序开发学习 > 正文

开发需求记录:自定义视频UI界面,全屏,倍速,手势控制(亮度,音量,进度),视频截图

作者:访客发布时间:2023-12-18分类:程序开发学习浏览:71


导读:前言平时项目中视频播放器使用饺子播放器,但某次项目中,无法播放后台视频。猜测是视频格式问题,之后尝试了几种播放器后,最终决定使用ExoPlayer实现。之后根据设计样式,对播放...

前言

平时项目中视频播放器使用饺子播放器 ,但某次项目中,无法播放后台视频。猜测是视频格式问题,之后尝试了几种播放器后,最终决定使用ExoPlayer实现。之后根据设计样式,对播放器UI进行修改,并添加视频全屏与非全屏切换功能。项目完成后想着进一步完善,方便下次使用。最终模仿常见的视频播放器功能,对自定义视频播放器功能进行补全。完善后视频播放器效果如下: 开发需求 - 视频播放器UI.gif

功能分析

  • 视频播放器UI
  • 视频全屏与非全屏切换
  • 视频倍速控制
  • 手势控制视频,亮度,音量,视频进度
  • 视频设置界面

代码实现

下面讲解的示例代码大多是从完整代码中截取出来,并进行了一些修改,例如讲解某功能时候,与该功能不相关的代码一般采用//...或者注解来替换。完整代码可在Github中下载。文章末尾也会贴出部分功能实现的完整代码。

视频播放器UI

ExoPlayer自定义较简单UI样式。可以通过创建exo_playback_control_view.xml文件,将xml文件名添加使用PlayerView控件的controller_layout_id属性。该属性用于指定自定义控制器布局,代码如下:

<com.google.android.exoplayer2.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:surface_type="texture_view"
    app:controller_layout_id="@layout/exo_playback_control_view"/>

exo_playback_control_view.xml文件内则可以定义视频控制器相关控件,在该xml文件中设置视频播放/暂停按钮,视频进度条等内容。该xml中需要注意下图红框部分控件 image.png 框中的控件为,视频播放按钮(ImageView),视频暂停按钮(ImageView),视频当前进度(TextView),视频总进度(TextView),视频进度条(com.google.android.exoplayer2.ui.DefaultTimeBar)。上述控件特殊的点在于,控件的id与PlayerView关联的播放控件的id相同。 例如进度条控件为com.google.android.exoplayer2.ui.DefaultTimeBar,且id为exo_progress时。只要在xml设置了进度条的样式,ExoPlayer就会处理进度条逻辑和样式。不需要在Activity或Fragment写额外代码控制进度条显示逻辑。例如下面是一个exo_playback_control_view.xml文件内的com.google.android.exoplayer2.ui.DefaultTimeBar控件,该控件是ExoPlayer默认视频进度条控件,buffered_color设置缓冲部分颜色,played_color已播放部分颜色,unplayed_color未播放部分颜色。

<com.google.android.exoplayer2.ui.DefaultTimeBar
    android:id="@id/exo_progress"
    android:layout_width="0dp"
    android:layout_height="26dp"
    android:layout_weight="1"
    app:buffered_color="@android:color/darker_gray"
    app:layout_constraintTop_toTopOf="@+id/bottom_line"
    app:layout_constraintBottom_toBottomOf="@+id/bottom_line"
    app:layout_constraintLeft_toRightOf="@id/exo_duration"
    app:layout_constraintRight_toLeftOf="@id/btn_volume"
    app:played_color="#FFDE81"
    app:unplayed_color="@android:color/black" />

之后ExoPlayer的PlayerView的controller_layout_id属性将exo_playback_control_view.xml文件添加上就好了。下面是一些ExoPlayer常见控件的id,可以根据需求使用。

id描述
exo_play播放按钮
exo_pause暂停按钮
exo_rew快退按钮
exo_ffwd快进按钮
exo_prev上一个视频按钮
exo_next下一个视频按钮
exo_duration显示视频总时长的文本控件
exo_position显示当前播放时长的文本控件
exo_progress显示视频进度的进度条控件
exo_controller_placeholder放置自定义控制器的容器控件

想知道更多的话,可以下载源代码,找到exo_playback_control_view.xml,找到控件id为exo_xxx,这种格式的控件,之后鼠标停留到exo_xxx上,按住ctrl键,点击exo_xxx,进入到values.xml,里面有其他的默认视频控件id。 image.pngimage.png 上面特殊的几个控件讲完,剩下的是需要自行处理的控件,例如下图中将视频控制界面分为四个部分。 image.png 1.视频顶部栏
退出键,视频标题,设置按钮。并且顶部栏还有从上到下,从黑到透明的装饰色背景
2.视频底部栏
视频播放,视频进度文本,视频总长度文本,进度条,音量,全屏按钮,并且底部栏还有从下到上,从黑到透明的装饰色背景
3.手势显示界面
根据手指划动的轨迹决定界面是显示控制视频亮度/音量/进度
4.视频设置界面
视频其他设置,目前是视频倍速和视频截图。

视频全屏与非全屏切换

全屏与非全屏切换是通过改变屏幕方向与PlayerView的宽高实现的。需要注意点是,进入全屏时候,保存非全屏状态下,PlayerView的宽高,保存好后将PlayerView的宽高设置为MATCH_PARENT来填充满父容器,退出全屏时候,将屏幕方向变为竖屏,且宽高恢复为之前保存的宽高,全屏与非全屏的实现代码如下:

    /**
     * 离开全屏
     */
    private fun exitFullscreen() {
        requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
        binding.apply {
            //退出全屏时候,将宽高重新赋值回来
            val layoutParams = playerView.layoutParams
            layoutParams.width = originalWidth
            layoutParams.height = originalHeight
            playerView.layoutParams = layoutParams
        }
    }

    /**
     * 进入全屏
     */
    private fun enterFullscreen() {
        if(originalWidth == 0){
            originalWidth = binding.playerView.width
            originalHeight = binding.playerView.height
        }
        requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
        // 设置 ExoPlayerView 的全屏下相关设置
        binding.apply {
            val layoutParams = playerView.layoutParams
            layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT
            layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
            playerView.layoutParams = layoutParams
        }
    }

视频倍速控制

视频倍速改变功能是通过PlayerView的playbackParameters类属性实现。该属性包含两个属性speed和pitch。分别表示播放速度和音频音调。更改播放与音频速度可通过playerView.playbackParameters = PlaybackParameters(2f),这样形式的代码更改。
更改倍速代码确定好后,下面对UI进行自定义。演示的GIF中可以看到,控制视频倍速的控件是一个组合控件,由一个垂直进度条和一个TexiView组成。该组合控件逻辑并不复杂,较复杂的是自定义垂直进度条控件实现。(组合控件和垂直进度条的完整代码会在文章末尾贴出,或者可以下载源码查看)。

垂直进度条

垂直进度条UI实现是通过,在onDraw方法方法中,按照白色实心圆角矩形,蓝色边框圆角矩形,蓝色实心进度条圆角矩形,大圆,小圆,上述顺序进行绘制。onDraw方法代码如下:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawRoundRect(progressStrokeRectLeft, progressStrokeRectTop, progressStrokeRectRight, progressStrokeRectBottom, rx, ry, fillRectPaint);
//        canvas.drawRoundRect(progressStrokeRectLeft, progressStrokeRectTop, progressStrokeRectRight, progressStrokeRectBottom, rx, ry, strokeRectPint);
        canvas.drawRoundRect(progressStrokeRectLeft + strokeWidth/2, progressStrokeRectTop + strokeWidth/2, progressStrokeRectRight - strokeWidth/2, progressStrokeRectBottom - strokeWidth/2, rx, ry, strokeRectPint);
        canvas.drawRoundRect(progressStrokeRectLeft, progressTop, progressStrokeRectRight, progressStrokeRectBottom, rx, ry, progressPaint);
        canvas.drawCircle(circleX, circleY, bigCircleRadius, bigCirclePaint);
        canvas.drawCircle(circleX, circleY, smallCircleRadius, smallCirclePaint);
    }

绘制需要注意点是,如果画笔的样式是(Paint.Style.STROKE)描边样式的话,画笔描边,是按照你指定的位置向两边开始绘制描边。例如画一条蓝色垂直的线从(x1,y1)点到(x2,y2)点。黑色线是将(x1,y1)点到(x2,y2)点的一条极细的线。当画笔描边的时候,是沿着这条线向两边开始延伸。有时画笔为描边样式,绘制的UI效果与预期不一样可能这个问题导致的。 image.png 发现这个问题是,在竖直垂直进度条的水平间隙为0的时候发现,当为0时候,描边有一部分超出了View,当把描边宽度也纳入描边矩形坐标计算的时候才解决这个问题。上面代码中,注释的代码就是出现描边绘制问题的代码。对比图如下:

image.png

在onDraw方法中使用到矩形和圆对应的坐标,坐标计算是在onTouchEvent方法内进行计算,在手指移动事件(ACTION_MOVE)里面的通过本次手指Y坐标-上次手指Y坐标,获取手指移动时在Y轴上的变换值,之后将Y轴变化值作用于,蓝色实心进度条圆角矩形的Top值,大圆的Y,小圆的Y。获取进度条进度的话,可以通过蓝色实心进度条圆角矩形的Bottom-Top/Height实现。对应代码如下

progress = (progressStrokeRectBottom - progressTop)/(progressStrokeRectBottom - progressStrokeRectTop);

之后在onTouchEvent方法的return前面添加postInvalidate()让View重绘。完整的onTouchEvent代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startY = event.getY();
            handler.removeCallbacks(runnable);
            break;
        case MotionEvent.ACTION_MOVE:
            endY = event.getY();
            float changeY = endY - startY;
            progressTop += changeY;
            if (progressTop < progressStrokeRectTop) {
                progressTop = progressStrokeRectTop;
            } else if (progressTop > progressStrokeRectBottom) {
                progressTop = progressStrokeRectBottom;
            }
            circleY = progressTop;
            startY = endY;
            progress = (progressStrokeRectBottom - progressTop)/(progressStrokeRectBottom - progressStrokeRectTop);
            if (onListener!=null){
                onListener.getProgress(progress);
            }
            break;
        case MotionEvent.ACTION_UP:
            if (onListener!=null){
                onListener.onFingerUp(progress);
            }
            handler.postDelayed(runnable,DELAY_TIME);
            break;
    }
    postInvalidate();
    return true;
}

进度条适配倍速功能注意

在实现时候遇到下面一些问题,需要注意下。

长时间未操作进度条,隐藏进度条联合控件

垂直进度条在显示后,手指若未点击一段时后会消失,且若手指拖动了进度条,松手后一段时候后View消失。 该功能实现,是垂直进度条View内创建一个Handler和一个接口。代码如下:

public interface OnListener{
    void getProgress(float progress);
    void onHideParent();

    default void onFingerUp(float progress) {

    }
}
private OnListener onListener;
public void setOnListener(OnListener onListener){
    this.onListener = onListener;
}

private Handler handler = new Handler();
private Runnable runnable = new Runnable() {
    @Override
    public void run() {
        if (onListener!=null){
            onListener.onHideParent();
        }
    }
};

如果看到垂直进度条的onTouchEvent方法,会发现在手指按下时候,会移除定时任务,手指抬起时执行定时任务。定时任务则是通知外部执行垂直进度条组合控件隐藏的代码。

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //...
            handler.removeCallbacks(runnable);
            break;
        case MotionEvent.ACTION_MOVE:
            //...
            break;
        case MotionEvent.ACTION_UP:
            //...
            handler.postDelayed(runnable,DELAY_TIME);
            break;
    }
    postInvalidate();
    return true;
}

ExoPlayer视频倍速不能频繁设置且不能设置为0倍速

该问题容易解决,可以只使用松手时候的进度条进度,以及接收到0进度时,转换成最小的视频倍速即可。

手势控制视频,亮度,音量,视频进度

该部分功能可以分成两部分,一个是手势判断,一个是视频亮度,音量,进度的控制。比较简单的部分是控制视频亮度等功能,下面先从该部分实现开始。

视频亮度,音量,进度的控制

控制视频亮度
首先xml中PlayerView的surface_type属性设置为texture_view。(在xml将surface_type属性设置为texture_view原因是PlayerView的surface_type默认为surface_view,而surface_view不能通过设置透明度的方式改变视频亮度),之后在Activity/Fragment内对PlayeView的videoSurfaceView的alpha设置透明度,透明度范围0f~1f。示例代码如下:
Xml代码

<com.google.android.exoplayer2.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:resize_mode="fit"
    app:controller_layout_id="@layout/exo_playback_control_view"
    app:surface_type="texture_view" />

Activity/Fragment内代码

private var brightness = 0f//亮度
//... 
binding.playerView.videoSurfaceView?.alpha = brightness

控制视频音量
通过设置ExoPlayer的PlayerView的volume属性控制视频音量。volume的范围是0f~1f。

控制视频进度
通过设置ExoPlayer的PlayerView的seekTo()方法控制视频播放进度,seekTo()方法接收以毫秒为单位的Long类型数据。示例代码如下:

private var seekToPosition = 0L//视频要跳转到的位置
//... 
exoPlayer?.seekTo(seekToPosition)

手势判断

该功能需要自己实现PlayerView的触摸事件,在触摸事件中处理手势判断。
在介绍功能前,需要说明下触摸事件使用到的常量

companion object {
    const val TAG = "ExoVideoActivity"
    const val VOLUME = "volume"
    const val BRIGHTNESS = "brightness"
    const val PLAY_SPEED = "play_speed"
    const val VIDEO_POSITION = "video_id_position"
    const val GESTURE_TYPE_VOLUME = "video_volume"//音量修改
    const val GESTURE_TYPE_BRIGHTNESS = "video_brightness"//亮度修改
    const val GESTURE_TYPE_PROGRESS = "video_progress"//视频进度修改
    const val GESTURE_TYPE_NULL = "null"
    const val GESTURE_RESPONSE_VIEW_WIGHT_PERCENT = 0.8f//手水平划动时,控制视频进度变化的虚拟的进度条长度占屏幕宽度的百分比 。虚拟的进度条想象中的,实际不存在
    const val MAX_PLAY_SPEED  = 2f//视频最快倍速 目前2倍速
    const val DEF_TIME = 100L//防止手指快速按下抬起 对 手势判断影响的时间
    const val MIN_MOVE_DISTANCE = 30//判断手指移动方向最小距离
}

下面简单说下实现思路,首先为了防止手指短暂触摸屏幕导致误触,需要记录手指按下与手指移动时的时间,当小于一定指定时间时,不进行手势判断。示例代码如下:

when (action) {
        MotionEvent.ACTION_DOWN -> {
            //...
            fingerDownTime = System.currentTimeMillis()
        }
        MotionEvent.ACTION_MOVE -> {
            //...
            fingerMoveTIme = System.currentTimeMillis()
            //防止手指按下抬起过快,对手势行为判断造成误判
            if (fingerMoveTIme - fingerDownTime > DEF_TIME){
                //手势类型判断 亮度 音量 进度
            }
            //...
        }
        MotionEvent.ACTION_UP -> {
            //...
        }   
    }
}

若触摸时间大于指定时间。则开始判断手指按下与手指移动时,x轴与y轴哪个先达到指定的变化值。若先是手指按下X-手指移动X>指定值,则手势类型判断为控制视频进度,若先是手指按下Y-手指移动Y>指定值,在根据手指按下时的X坐标判断在屏幕左边还是右边。在屏幕左边为控制亮度,屏幕右边控制音量。示例代码如下:

when (action) {
    MotionEvent.ACTION_DOWN -> {
        //..
        downX = event.x
        downY = event.y
        gestureType = GESTURE_TYPE_NULL
        fingerDownTime = System.currentTimeMillis()
    }
    MotionEvent.ACTION_MOVE -> {
        moveX = event.x
        moveY = event.y
        fingerMoveTIme = System.currentTimeMillis()
        //防止手指按下抬起过快,对手势行为判断造成误判
        if (fingerMoveTIme - fingerDownTime > DEF_TIME){
            //判断当前是什么事件 进度 音量 亮度
            when (gestureType) {
                GESTURE_TYPE_VOLUME -> {
                    //手势为控制音量时,对应代码
                }
                GESTURE_TYPE_BRIGHTNESS -> {
                    //手势为控制亮度时,对应代码
                }
                GESTURE_TYPE_PROGRESS -> {
                    //手势为控制视频进度时,对应代码
                }
                else -> { 
                    //通过X和Y轴移动距离判断是那种手势方式
                    if(abs(downX - moveX) > MIN_MOVE_DISTANCE){//左右划
                        gestureType = GESTURE_TYPE_PROGRESS//视频进度
                    }else if(abs(downY - moveY) > MIN_MOVE_DISTANCE){//上下划
                        if (startX < playerViewWidth / 2){
                            gestureType = GESTURE_TYPE_BRIGHTNESS//视频亮度
                        }else{
                            gestureType = GESTURE_TYPE_VOLUME//视频音量
                        }
                    }
                }
            }
        }
        //...
    }
    MotionEvent.ACTION_UP -> {
        //...
    }
}

视频音量

当判断好手势类型后,之后在手指移动事件中计算对于事件需要的属性值。其中音量变化与亮度变化类似,下面以音量变化为例。首先看下相关的代码:

when (action) {
    MotionEvent.ACTION_DOWN -> {
        startX = event.x
        startY = event.y
        //...
        playerViewWidth = binding.playerView.width
        playerViewHeight = binding.playerView.height
        gestureType = GESTURE_TYPE_NULL
        //...
        fingerDownTime = System.currentTimeMillis()
    }
    MotionEvent.ACTION_MOVE -> {
        endX = event.x
        endY = event.y
        //...
        fingerMoveTIme = System.currentTimeMillis()
        //防止手指按下抬起过快,对手势行为判断造成误判
        if (fingerMoveTIme - fingerDownTime > DEF_TIME){
            //判断当前是什么事件 进度 音量 亮度
            when (gestureType) {
                GESTURE_TYPE_VOLUME -> {
                    volumeChange = (startY - endY) / (playerViewHeight.toFloat() / 2f)//音量变化
                    volume += volumeChange
                    if (volume > 1f){
                        volume = 1f
                    }else if (volume < 0f){
                        volume = 0f
                    }
                    player?.volume = volume
                    tvMsg1.text = "${(volume*100).toInt()}%"
                    imgGestureType.setImageResource(R.mipmap.volume_64_white)
                    gestureViewSet(gestureType)
                }
                GESTURE_TYPE_BRIGHTNESS -> {
                    //...
                }
                GESTURE_TYPE_PROGRESS -> {
                    //...
                }
                else -> { 
                    //...
                }
            }
        }
        //下面将startX重新赋值
        startX = endX
        startY = endY
    }
    MotionEvent.ACTION_UP -> {
        //...
    }
}

根据上面代码可以看出,当手势为控制音量,每次手指移动时,音量变化百分比 = ( 手指上次移动Y - 手指本次Y ) / PlayerView高度一半。对应代码:

volumeChange = (startY - endY) / (playerViewHeight.toFloat() / 2f)//音量变化

之后将变化的音量作用于视频音量,当视频音量大于1f,将视频音量改为1f,小于0f,则改为0f。之后将音量作用于PlayerView,并设置与手势相关View显示的文本内容和图片。对应代码如下:

volume += volumeChange
if (volume > 1f){
    volume = 1f
}else if (volume < 0f){
    volume = 0f
}
player?.volume = volume
tvMsg1.text = "${(volume*100).toInt()}%"
imgGestureType.setImageResource(R.mipmap.volume_64_white)
gestureViewSet(gestureType)

gestureViewSet()方法是根据手势类型,决定视频中间的手势View应该显示什么内容。

图片.png 上图可以看到,手势View由一个ImageView两个TextView构成,音量与亮度使用一个ImageVIew与一个TextView,视频进度则是两个TextView。因此在手指移动时候需要考虑哪些View该显示或隐藏。
并且手势View在exo_playback_control_view.xml文件中。当手指触摸屏幕时候,应该是xml文件内所有的View都显示。如下图箭头所指顶部与底部的View。

图片.png 因此手指触摸导致PlayerView的controlView显示时候(controlView就是exo_playback_control_view.xml内对应的所有View),需要隐藏顶部与底部的View,显示中间的手势View。gestureViewSet()方法对应代码如下:

/** 手势View该显示内容设置 */
private fun gestureViewSet(type:String) {
    if (!isVisibleGestureView) {
        clGestureType.visibility = View.VISIBLE
        when(type){
            GESTURE_TYPE_BRIGHTNESS, GESTURE_TYPE_VOLUME->{
                imgGestureType.visibility = View.VISIBLE
                tvMsg1.visibility = View.VISIBLE
            }
            GESTURE_TYPE_PROGRESS ->{
                imgGestureType.visibility = View.GONE
                tvMsg1.visibility = View.VISIBLE
                tvMsg2.visibility = View.VISIBLE
            }
        }
        if (!controlView.isVisible) {
            controlView.show()
            clVideoTop.visibility = View.GONE
            clVideoBottom.visibility = View.GONE
        }
        isVisibleGestureView = true
    }
}

当松手时候,就需要将controlView的顶部与底部的View变为可见。同时隐藏手势View。若controlView的顶部与底部的View不可见,轻触PlayerView是不会显示的。对应代码如下:

MotionEvent.ACTION_UP -> {
    when(gestureType){//松手时候改变视频进度,若放在移动中,触发比较频繁。可根据需求修改
        GESTURE_TYPE_PROGRESS ->{
            exoPlayer?.seekTo(seekToPosition)
        }
    }
    //手指抬起时候,一些界面消失
    if (isVisibleSetView) {//设置界面存在,先处理设置界面
        videoSetViewSwitch(1000)
    } else if (vpLayout.visibility == View.VISIBLE){
        vpLayout.visibility = View.GONE
    } else if(isVisibleGestureView){//手势控件
        if (clVideoTop.visibility == View.GONE){//若顶部栏原本不可见,后面隐藏视频控制界面
            controlView.hide()
        }
        clVideoTop.visibility = View.VISIBLE //这里将顶部与底部控件可见,为了手势操作完后,点击屏幕显示顶部与底部视频栏
        clVideoBottom.visibility = View.VISIBLE
        clGestureType.visibility = View.GONE
        imgGestureType.visibility = View.GONE
        tvMsg1.visibility = View.GONE
        tvMsg2.visibility = View.GONE
        isVisibleGestureView = false
    } else {
        if (controlView.isVisible) {
            controlView.hide()
        } else {
            controlView.show()
        }
    }
}

视频倍速

视频倍速功能,是手指松手后改变视频播放位置,因此需要记录手指按下时候视频进度。之所以手指松开才设置视频进度,是担心频繁使用PlayerView的seekTo()方法会出问题。之后手指移动时候,需要考虑哪些View该显示隐藏(调用gestureViewSet()方法)。并计算出,需要跳至哪个时刻,以及具体跳过的时间。同时对跳至的时刻做判断。时刻不能是负数也不能超过视频长度。手指松开时候,在使用seekTo()方法跳到指定时刻。对应代码如下:

event?.apply {
    when (action) {
        MotionEvent.ACTION_DOWN -> {
            downX = event.x
            downY = event.y
            //...
            fingerDownVideoPosition = (player?.currentPosition ?: 0L)
            skipVideoDuration = 0L
            fingerDownTime = System.currentTimeMillis()
        }
        MotionEvent.ACTION_MOVE -> {
            //...
            moveX = event.x
            moveY = event.y
            fingerMoveTIme = System.currentTimeMillis()
            //防止手指按下抬起过快,对手势行为判断造成误判
            if (fingerMoveTIme - fingerDownTime > DEF_TIME){
                //判断当前是什么事件 进度 音量 亮度
                when (gestureType) {
                    GESTURE_TYPE_VOLUME -> {
                        //...
                    }
                    GESTURE_TYPE_BRIGHTNESS -> {
                        //...
                    }
                    GESTURE_TYPE_PROGRESS -> {
                        gestureViewSet(gestureType)
                        //变化进度View宽度
                        skipVideoDuration = ((moveX - downX) / (playerViewWidth * GESTURE_RESPONSE_VIEW_WIGHT_PERCENT) * (player?.duration ?: 0L)).toLong()
                        seekToPosition = fingerDownVideoPosition + skipVideoDuration
                        if (seekToPosition > (player?.duration ?: 0L)){
                            seekToPosition = player?.duration ?: 0L
                            skipVideoDuration = (player?.duration ?: 0L) - fingerDownVideoPosition
                        }else if (seekToPosition < 0L){
                            seekToPosition = 0
                            skipVideoDuration = fingerDownVideoPosition
                        }
                        tvMsg1.text = "${TimeUtil.formatMillisToHMS(seekToPosition)}/${TimeUtil.formatMillisToHMS(player?.duration ?: 0L)}"
                        if (downX - moveX > 0) {
                            tvMsg2.text = "-${TimeUtil.formatMillisToHMS(abs(skipVideoDuration))}"
                        } else {
                            tvMsg2.text = "+${TimeUtil.formatMillisToHMS(abs(skipVideoDuration))}"
                        }
                    }
                    else -> { 
                        //...
                    }
                }
            }
            //...
        }
        MotionEvent.ACTION_UP -> {
            when(gestureType){//松手时候改变视频进度,若放在移动中,触发比较频繁。可根据需求修改
                GESTURE_TYPE_PROGRESS ->{
                    exoPlayer?.seekTo(seekToPosition)
                }
            }
            //... View显示或隐藏相关操作
        }
    }
}

跳过时长计算规则可以简述为下面的形式。
跳过时长 = ((手指移动X - 手指按下X) / (PlayeView宽度 * 百分比) * 视频时长)
对应代码如下

skipVideoDuration = ((moveX - downX) / (playerViewWidth * GESTURE_RESPONSE_VIEW_WIGHT_PERCENT) * (player?.duration ?: 0L)).toLong()

视频设置界面

视频设置界面实现原理是,可见界面的右侧设置一个GroupView,并设置对应的内容。如下图红色箭头所指部分。 image.png 设置界面出现,是通过属性值动画设置ViewGroup的x坐标实现设置界面移动。当点击设置按钮时候,先判断动画是否存在或者动画是否运行中,动画若不存在或未运行,才开始动画相关设置并开启动画。点击设置按钮时,对应的代码如下:

/**
 * 视频设置界面切换
 * duration 动画持续事件
 */
private fun videoSetViewSwitch(duration: Long) {
    findViewById<LinearLayout>(R.id.cl_video_set).apply {
        playerViewWidth = binding.playerView.width
        if (valueAnimator == null || valueAnimator?.isRunning == false) {//动画不存在或动画未运行
            valueAnimator = ValueAnimator.ofInt(
                if (isVisibleSetView) this.width else 0,
                if (isVisibleSetView) 0 else this.width
            )
            valueAnimator?.addUpdateListener {
                x = (playerViewWidth - it.animatedValue as Int).toFloat()
            }
            valueAnimator?.duration = duration
            valueAnimator?.start()
            isVisibleSetView = !isVisibleSetView
        }

        //此处设置点击方法,防止覆盖的设置按钮点击事件影响。点击拦截
        setOnClickListener {

        }
    }
}

需要注意的是需要重写ViewGroup的点击方法,来拦截点击事件。不然如果设置界面弹出后,如果在点击设置按钮对应位置,会发现又执行了设置按钮点击事件内相关操作。

如果看运行效果图,设置界面,是三个视频截面的按钮,分别使用MediaMetadataRetriever,FFmpeg,和Glide截图。

有三种截图方式原因是,一开始实现截图功能是通过MediaMetadataRetriever实现,这样不需要引入第三方依赖。但这种方式截图,安卓版本大于等于27的可以精确截图,27以下截图不准确。发现不精准后,后想到曾使用Glide做视频预览图,能做预览图,应该就能获取到Bitmap并保存为图片文件。但实现后发现也有低版本安卓截取不准确的问题。最终使用FFmpeg实现视频截图功能。

对三种截图方式做总结的话,MediaMetadataRetriever不需要引入第三方依赖,但有低版本安卓截图不准确问题。Glide本身不是做视频截图,只是能实现功能,这这里仅记录。FFmpeg可以实现高低版本精确截图,但需要导入第三方依赖,且视频截图时间比前两者时间长。

下面就只介绍FFmpeg方式实现截图。 首先导入依赖

implementation 'com.arthenica:ffmpeg-kit-full:5.1'

截图代码

/** 截图 - 通过FFmpeg 为了适配低版本Android */
private fun screenshotByFFmpeg(url: String, timeInMillis: Long, outputPath: String) {
    val time = TimeUtil.formatMillisToHMSM(timeInMillis)
    val command = "-ss $time -i $url -vframes 1 -q:v 2 $outputPath"
    val session = FFmpegKit.execute(command)
    if (ReturnCode.isSuccess(session.returnCode)) {
        //success
        ToastUtil.show("截图成功")
        CustomLog.d(TAG, "成功")
    } else if (ReturnCode.isCancel(session.returnCode)) {
        //cancel
        ToastUtil.show("截图失败")
        CustomLog.d(TAG, "取消")
    } else {
        //fail
        CustomLog.d(TAG, "失败")
    }
}

上面代码中,FFmpeg命令解释如下:

"-ss", timeSeconds.toString(), // 设置截图时间点
"-i", inputVideoPath, // 输入视频文件路径
"-vframes", "1", // 截取一帧图像
"-q:v", "2", // 设置图像质量(可选)
outputImagePath // 输出截图文件路径

其中设置视频截图时间点不能是Long类型数据,而是00:00:00或者00:00:00:000格式。第一个是时:分:秒,第二个是时:分:秒:毫秒。截图使用第二种格式。

总结

自定义视频播放界面,比较多的时间花在了UI和手势判断上。最开始实现手势判断的时候,是使用模拟器测试,鼠标触摸PlayerView的时候,鼠标点一下是手指按下,鼠标移动才能触发手指移动事件。就没有考虑触摸时间问题,手势判断最开始是没有if (fingerMoveTIme - fingerDownTime > DEF_TIME)这个判断。但真机测试时候,手指按下是会触发手指按下与手指移动的。毕竟现实中手指与屏幕接触是一个面,而不是一个点,现实中手指按下时,ACTION_DOWN与ACTION_MOVE事件都触发的。这就导致明明只是想点下屏幕,显示下控制视频的相关控件,结果却是手势响应了。之后将触摸时间考虑进去后,就解决这个问题。还有些其他开发遇到问题。一般我会在代码中注释写出来。

代码地址

GitHub:github.com/SmallCrispy…


标签:视频自定义全屏音量手势亮度


程序开发学习排行
最近发表
网站分类
标签列表