文章目录

  • 前言
  • 一、先来张效果图
  • 二、使用步骤
  • 1.配置清单文件
  • 2.编写 Service
  • 3. Activity
  • 4.请求权限
  • 5.浮窗的页面贴一下
  • 三、画中画
  • 总结



前言

本篇以简单的浮窗视频为例, 练习 Service, 浮窗, MediaPlayer视频播放等;

本篇涉及内容:

  • Service 的基本用法;
  • MediaPlayer 播放本地视频
  • 通过 WindowManager 添加浮窗
  • Android Result API 自定义协议类, 校验浮窗权限

一、先来张效果图



android 视频页面 小窗口 android小窗口播放视频_初始化

二、使用步骤

1.配置清单文件

<!-- SD卡读写权限 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> 
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<!-- 8.0 以上 前台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<!-- 悬浮窗权限 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

<application
	...
	<!-- 读取本地文件需要 -->
	 android:requestLegacyExternalStorage="true" >
	
	<!-- 自定义的视频浮窗 Service -->
	<service android:name=".test.textservice.VideoFloatingService"/>



2.编写 Service

这次的 Service 并没有 上一篇 简单的音乐播放器 中的 Service 复杂;
只需要初始化 MediaPlayer, 播放视频. 添加悬浮窗. 以及内部简单的控制逻辑;

class VideoFloatingService: Service() {
    private val TAG = "VideoFloatingService"

    private lateinit var windowManager: WindowManager
    private lateinit var layoutParams: WindowManager.LayoutParams

    private lateinit var binding: ViewVideoFloatingBinding
    private lateinit var mediaPlayer: MediaPlayer

    override fun onCreate() {
        super.onCreate()
        init()  // 初始化播放器
        showFloatingWindow()    // 添加浮窗
    }

    override fun onBind(intent: Intent?): IBinder? = null

    private fun showFloatingWindow() {
        windowManager = getSystemService(WINDOW_SERVICE) as WindowManager

        // 初始化浮窗 layoutParams
        layoutParams = WindowManager.LayoutParams().also {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                it.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            } else {
                it.type = WindowManager.LayoutParams.TYPE_PHONE
            }

            it.format = PixelFormat.RGBA_8888
            it.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE

            // 浮窗位置和尺寸, 偏移量等
            it.gravity = Gravity.CENTER_VERTICAL or Gravity.START
            it.width = WindowManager.LayoutParams.WRAP_CONTENT
            it.height = WindowManager.LayoutParams.WRAP_CONTENT
            it.x = 0
            it.y = 0
        }

        // 初始化浮窗布局, 为播放按钮添加事件; 为整个浮窗添加触摸监听(满足拖动, 点击等)
        binding = ViewVideoFloatingBinding.inflate(LayoutInflater.from(this))
        binding.ivPlay.setOnClickListener {
            binding.ivPlay.setImageResource(if(mediaPlayer.isPlaying){
                mediaPlayer.pause()
                R.mipmap.video_icon_play
            }else{
                mediaPlayer.start()
                R.mipmap.video_icon_suspend
            })
        }
        // 添加拖拽事件
        binding.root.setOnTouchListener(FloatingListener())

        // 要等 SurfaceHolder Created
        binding.svVideo.holder.addCallback(object : SurfaceHolder.Callback {
            override fun surfaceCreated(holder: SurfaceHolder) {
                mediaPlayer.setDisplay(binding.svVideo.holder)
            }
            override fun surfaceChanged(s: SurfaceHolder, f: Int, w: Int, h: Int ) {}
            override fun surfaceDestroyed(holder: SurfaceHolder) {}
        })

        // 添加浮窗
        windowManager.addView(binding.root, layoutParams)
    }

    private fun init() {
        mediaPlayer = MediaPlayer().also {
            it.isLooping = true // 循环播放

            val file = File(Environment.getExternalStorageDirectory(),"big_buck_bunny.mp4")
            it.setDataSource(file.path)

            it.prepareAsync()
            it.setOnPreparedListener {
                mediaPlayer.start()
            }
        }
    }

    override fun onDestroy() {
        mediaPlayer.stop()
        mediaPlayer.release()
        windowManager.removeView(binding.root)
        super.onDestroy()
    }

    inner class FloatingListener: View.OnTouchListener{
        private var x = 0   // 当前位置值
        private var y = 0
        private var cx = 0  // 点击初始值;
        private var cy = 0
        private var checkClick: Boolean = false

        @SuppressLint("ClickableViewAccessibility")
        override fun onTouch(v: View?, event: MotionEvent?): Boolean {
            when(event?.action){
                MotionEvent.ACTION_DOWN -> {
                    checkClick = true
                    cx = event.rawX.toInt()
                    cy = event.rawY.toInt()
                    x = cx
                    y = cy
                }
                MotionEvent.ACTION_MOVE -> {
                    val nowX = event.rawX.toInt()
                    val nowY = event.rawY.toInt()
                    val movedX = nowX - x
                    val movedY = nowY - y
                    x = nowX
                    y = nowY
                    layoutParams.let {
                        it.x = it.x + movedX
                        it.y = it.y + movedY
                    }

                    // 更新悬浮窗控件布局
                    windowManager.updateViewLayout(binding.root, layoutParams)

                    if (checkClick && (abs(x - cx) >= 5 || abs(y - cy) >= 5)) {
                        checkClick = false
                    }
                }
                MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
                    if (checkClick && abs(cx - event.rawX.toInt()) < 5 && abs(cy - event.rawY.toInt()) < 5) {
                        // 判断为 浮窗点击事件;  控制播放按钮显隐;
                        if(binding.ivPlay.visibility == View.VISIBLE){
                            binding.ivPlay.visibility = View.GONE
                        }else{
                            binding.ivPlay.visibility = View.VISIBLE
                            binding.ivPlay.setImageResource(if(mediaPlayer.isPlaying){
                                R.mipmap.video_icon_suspend
                            } else {
                                R.mipmap.video_icon_play
                            })
                        }
                    }
                    checkClick = false
                }
            }
            return true
        }
    }
}

代码看起来不少, 但逻辑却很简单; 总体来看只有 初始化播放器, 添加浮窗, 添加触摸监听 等;



3. Activity

Activity 就很简单了, 先校验或请求一下权限. 然后直接启动 Service 就完事了;

// 点击事件
fun onClick(v: View) {
   when(v.id){
       R.id.tv_one -> {
           startFloatingWindow()	// 检验权限后 开启Service
       }
       R.id.tv_two -> {
           stopService(Intent(this, VideoFloatingService::class.java))
       }
   }
}

private fun startFloatingWindow() {
	// Android Result Api 的方式, 请求权限
    launcher2.launch(null)  
}

private val launcher2 = registerForActivityResult(RequestFloating(this)) {
    if(it){
    	// toast("") 这是 kotlin 自定义的扩展函数. 
        toast("有权限")	
        // 有了权限, 直接启动 Service 即可
        startService(Intent(this, VideoFloatingService::class.java))
    }else{
        toast("授权失败")
    }
}



4.请求权限

这是 俺自定义的 协议类; 有权限时 同步响应回调; 没有权限, 则跳转开启权限的页面;

class RequestFloating(val context: Context): ActivityResultContract<Unit, Boolean>(){
    @RequiresApi(Build.VERSION_CODES.M)
    override fun createIntent(context: Context, input: Unit?) = Intent().also {
            it.action = Settings.ACTION_MANAGE_OVERLAY_PERMISSION
            it.data = Uri.parse("package:${context.packageName}")
        }

    override fun getSynchronousResult(context: Context, input: Unit?): SynchronousResult<Boolean>? {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
            null
        } else {
            SynchronousResult(true)
        }
    }

    override fun parseResult(resultCode: Int, intent: Intent?): Boolean{
        return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(context)
    }
}

不了解 Android Result Api 的小伙伴可以看我这篇文章: Android Result API

当然也可以用下面这段代码:

//检查悬浮窗权限,小于6.0系统不需要权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
     Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
     intent.setData(Uri.parse("package:" + getPackageName()));
     startActivityForResult(intent, 100);
 }else{
     //已有权限
 }

这种. 还得重写 onActivityResult; 但是 onActivityResult 中并没有结果, 需要自己再判断一遍



5.浮窗的页面贴一下

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
        <SurfaceView
            android:id="@+id/sv_video"
            android:layout_width="180dp"
            android:layout_height="108dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>
        <ImageView
            android:id="@+id/iv_play"
            style="@style/img_wrap"
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:src="@mipmap/video_icon_play"
            android:visibility="gone"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Activity 的页面就不贴了; 就俩按钮.
至此, 简单的视频浮窗播放 Service 完成 😀



三、画中画

博主本想在下一篇文章中 用画中画实现视频浮窗, 但发现写不出什么东西.
这里干脆只把关键代码帖一帖吧. 就当笔记了.

推荐文章:
官方文档 - 画中画支持

1.清单文件配置

<!-- android:supportsPictureInPicture="true" 是关键代码 - 开启画中画支持 -->
<activity android:name=".test.textservice.VideoDetailActivity"
    android:launchMode="singleTask"
    android:resizeableActivity="true"
    android:supportsPictureInPicture="true"
    android:configChanges=
        "screenSize|smallestScreenSize|screenLayout|orientation"/>

2.进入画中画模式

/**
  *进入画中画模式
  */
private var mPictureInPictureParamsBuilder: PictureInPictureParams.Builder? = null
private fun enterPiPMode() {
	// 
    // 低内存设备可能无法使用画中画模式。 进行检查以确保可以使用画中画。
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
        packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
        if (mPictureInPictureParamsBuilder == null) {
            mPictureInPictureParamsBuilder = PictureInPictureParams.Builder()
        }
        // 这是设置窗口宽高比例;  纵横比不能太极端(必须在0.418410和2.390000之间)。
        val aspectRatio = Rational(binding.svVideo.width, binding.svVideo.height)
        mPictureInPictureParamsBuilder?.setAspectRatio(aspectRatio)
        
        //进入pip模式
        enterPictureInPictureMode(mPictureInPictureParamsBuilder!!.build())
    }
}

3.在Activity中监听 PIP模式的切换

override fun onPictureInPictureModeChanged(
    isInPictureInPictureMode: Boolean,
    newConfig: Configuration?
) {
    super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
    if (isInPictureInPictureMode) {
        // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
        // 隐藏多余 View ...
    } else {
        // Restore the full-screen UI.
        // 恢复隐藏的 View ...
    }
}

需要注意, 启动画中画模式时, Activity 生命周期是处于 paused 状态. 所以控制食品播放和暂停 可以放在 onStart() onStop() 中

画中画模式中, 事件是传不到 Activity 中的, 所以想要自定义窗口操作的UI, 需要额外处理.


总结

新手还是建议 自己把代码敲一下. 涉及的东西还是不少的. 至于视频播放, 博主会在下一篇文章中, 用画中画.. 好吧不用了