音频播放器浮窗+通知栏播放器控制

一、最终效果如图

二、音频播放器浮窗实现

原理:

1、创建单例类FloatPlayer,内部创建浮窗播放器的布局,通过MediaPlayer去实现音频的播放。并暴露出开启、显示、隐藏、关闭浮窗播放器的方法供外部调用。

2、因为是通过Window.addView()、removeView()方法在每个页面去显示、隐藏浮窗播放器(这种方法优点:不用申请系统弹窗权限。缺点:每个页面都要处理。),需要在页面的基类BaseActivity里边的onResume()、onPause()方法里调用FloatPlayer的显示(判断是否启动了播放器,若启动则显示,否则不显示)、隐藏方法。

0、布局文件

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/csRootFloatPlayer"
    android:layout_width="168dp"
    android:layout_height="48dp">

    <com.example.floatplayer.PlayerBgView
        android:id="@+id/bgViewPlayer"
        android:layout_width="168dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_height="48dp"/>

    <com.google.android.material.imageview.ShapeableImageView
        android:id="@+id/sivPlayerCover"
        android:layout_width="@dimen/player_icon_width"
        android:layout_height="0dp"
        android:layout_marginStart="8dp"
        android:scaleType="centerCrop"
        android:src="@mipmap/ic_player_cover"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintDimensionRatio="1"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:shapeAppearance="@style/imgStyleCircle" />

    <ImageView
        android:id="@+id/ivPlayerControl"
        android:layout_width="@dimen/player_icon_width"
        android:layout_height="0dp"
        android:layout_marginStart="@dimen/player_icon_margin_start"
        android:contentDescription="@null"
        android:src="@drawable/ic_baseline_play_arrow_24"
        app:layout_constraintBottom_toBottomOf="@+id/sivPlayerCover"
        app:layout_constraintDimensionRatio="1"
        app:layout_constraintStart_toEndOf="@+id/sivPlayerCover"
        app:layout_constraintTop_toTopOf="@+id/sivPlayerCover"
        app:tint="@color/float_play_icon" />

    <ImageView
        android:id="@+id/ivPlayerNext"
        android:layout_width="@dimen/player_icon_width"
        android:layout_height="0dp"
        android:layout_marginStart="@dimen/player_icon_margin_start"
        android:contentDescription="@null"
        android:src="@drawable/ic_baseline_skip_next_24"
        app:layout_constraintBottom_toBottomOf="@+id/sivPlayerCover"
        app:layout_constraintDimensionRatio="1"
        app:layout_constraintStart_toEndOf="@+id/ivPlayerControl"
        app:layout_constraintTop_toTopOf="@+id/sivPlayerCover"
        app:tint="@color/float_play_icon" />

    <ImageView
        android:id="@+id/ivPlayerClose"
        android:layout_width="@dimen/player_icon_width"
        android:layout_height="@dimen/player_icon_width"
        android:layout_marginStart="@dimen/player_icon_margin_start"
        android:contentDescription="@null"
        android:src="@drawable/ic_baseline_close_24"
        app:layout_constraintBottom_toBottomOf="@+id/sivPlayerCover"
        app:layout_constraintStart_toEndOf="@+id/ivPlayerNext"
        app:layout_constraintTop_toTopOf="@+id/sivPlayerCover"
        app:tint="@color/float_play_icon" />

</androidx.constraintlayout.widget.ConstraintLayout>

圆形封面图样式style:imgStyleCircle

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--ShapeImageView圆形图-->
    <style name="imgStyleCircle">
        <item name="cornerFamily">rounded</item>
        <item name="cornerSize">50%</item>
    </style>
</resources>

1、FloatPlayer

import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.media.MediaPlayer
import android.os.Build
import android.transition.TransitionManager
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.animation.LinearInterpolator
import android.widget.Toast
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.app.NotificationCompat
import com.example.floatplayer.databinding.FloatPlayerViewBinding

class FloatPlayer private constructor() {

    //播放器是否活动
    private var isPlayerActive = false

    //悬浮窗是否正在显示
    private var isShowing = false
    private lateinit var bindingFloatPlayer: FloatPlayerViewBinding
    private val mCsApply = ConstraintSet()
    private val mCsReset = ConstraintSet()
    private var mContext: Context? = null

    //播放状态(默认不播放)
    private var isPlaying = false

    //控件展开状态(默认展开)
    private var isExpansion = true
    private lateinit var animCoverRotation: ObjectAnimator
    private var mediaPlayer: MediaPlayer? = null

    //音乐列表
    private val mMusicList = arrayListOf(R.raw.shanghai, R.raw.withoutyou)
    private var mMusicPosition = 0
    var mPlayControlReceiver: PlayerActionBroadCastReceiver = PlayerActionBroadCastReceiver()
    private lateinit var mNotificationManager: NotificationManager

    companion object {

        const val notificationMediaId = 10010
        const val notificationChannelMedia = "MediaNotification"

        @Volatile
        private var instance: FloatPlayer? = null

        fun getInstance() = instance ?: synchronized(this) {
            instance ?: FloatPlayer().also { instance = it }
        }
    }

    init {

        initNotificationManager()

        initView()
    }

    private fun initNotificationManager() {

        mNotificationManager = FloatApp.appContext
            .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            mNotificationManager.createNotificationChannel(
                NotificationChannel(
                    notificationChannelMedia,
                    "播放器", NotificationManager.IMPORTANCE_DEFAULT
                )
            )
        }
    }

    //显示播放控件
    fun show(context: Context) {
        if (!isPlayerActive) return
        mContext = context
        initMediaPlayer()
        bindingFloatPlayer.root.visibility = View.VISIBLE
        val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
        windowManager.addView(bindingFloatPlayer.root, createLayoutParam(context))
        isShowing = true
    }

    fun dismiss() {
        if (!isShowing || mContext == null) return
        bindingFloatPlayer.root.visibility = View.INVISIBLE
        val windowManger = mContext!!.getSystemService(Context.WINDOW_SERVICE) as WindowManager
        windowManger.removeView(bindingFloatPlayer.root)
        isShowing = false
        mContext = null
    }

    //开启展示播放控件
    fun open(context: Context) {
        if (!isPlayerActive) {
            isPlayerActive = true
            show(context)
        }
    }

    //是否正在播放
    fun playing(): Boolean {
        return mediaPlayer?.isPlaying == true
    }

    //播放/暂停切换
    fun playSwitch() {
        if (mediaPlayer == null) return
        bindingFloatPlayer.ivPlayerControl.performClick()
    }

    //切换下一首
    fun playNext() {
        if (hasNext()) {
            bindingFloatPlayer.ivPlayerNext.performClick()
        } else {
            showToast("没有更多了")
        }
    }

    //关闭播放控件
    fun close() {
        if (!isPlayerActive || mContext == null) return
        destroyMediaPlayer()
        dismiss()
        isPlayerActive = false
    }

    //创建LayoutParam
    private fun createLayoutParam(context: Context): WindowManager.LayoutParams {

        val layoutParam = WindowManager.LayoutParams()
        layoutParam.width = WindowManager.LayoutParams.WRAP_CONTENT
        layoutParam.height = WindowManager.LayoutParams.WRAP_CONTENT
        //弹窗层级
        layoutParam.type = WindowManager.LayoutParams.TYPE_APPLICATION
        layoutParam.gravity = Gravity.START or Gravity.BOTTOM
        //背景透明
        layoutParam.format = PixelFormat.TRANSPARENT
        layoutParam.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        layoutParam.x =
            TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_DIP, 24f,
                context.resources.displayMetrics
            ).toInt()
        layoutParam.y =
            TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_DIP, 80f,
                context.resources.displayMetrics
            ).toInt()
        return layoutParam
    }

    //初始化控件
    private fun initView() {

        bindingFloatPlayer =
            FloatPlayerViewBinding.inflate(LayoutInflater.from(FloatApp.appContext))
        mCsApply.clone(bindingFloatPlayer.root)
        mCsReset.clone(bindingFloatPlayer.root)

        bindingFloatPlayer.sivPlayerCover.setOnClickListener {
            playExpansionStatusSwitch(!isExpansion)
            bindingFloatPlayer.bgViewPlayer.doAnimation()
        }

        bindingFloatPlayer.ivPlayerControl.setOnClickListener {
            playControlStatusSwitch(!isPlaying)
            updateNotification()
        }

        bindingFloatPlayer.ivPlayerNext.setOnClickListener {
            mediaPlayNext()
        }

        bindingFloatPlayer.ivPlayerClose.setOnClickListener {
            playControlStatusSwitch(false)
            cancelNotificationMedia()
            close()
        }

        initRotationAnimator(bindingFloatPlayer.sivPlayerCover)
    }

    //初始化音频播放器
    private fun initMediaPlayer() {

        if (mediaPlayer != null) return
        mediaPlayer = MediaPlayer.create(FloatApp.appContext, mMusicList[0])
        mediaPlayer!!.setOnCompletionListener {
            mediaPlayNext()
        }
        mediaPlayer!!.setOnErrorListener { _, _, _ ->
            mediaPlayError()
            true
        }
    }

    //销毁MediaPlayer
    private fun destroyMediaPlayer() {
        mediaPlayer?.stop()
        mediaPlayer?.release()
        mediaPlayer = null
    }

    //是否还有下一首
    private fun hasNext(): Boolean {
        return mMusicList.isNotEmpty() && mMusicPosition < mMusicList.size - 1
    }

    //创建音频播放器
    private fun mediaPlayNext() {

        if (!hasNext()) {
            showToast("没有更多了")
        } else {

            mediaPlayer?.stop()
            mediaPlayer?.release()

            mMusicPosition++
            mediaPlayer = MediaPlayer.create(
                FloatApp.appContext,
                mMusicList[mMusicPosition]
            )
            mediaPlayer!!.start()
            playControlStatusSwitch(true)
            updateNotification()
        }
    }

    private fun showToast(message: String) {
        if (mContext == null) return
        Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
    }

    //开始播放音频
    private fun mediaPlayStart() {
        if (mediaPlayer?.isPlaying == true) return
        mediaPlayer?.start()
    }

    //暂停播放音频
    private fun mediaPlayPause() {
        if (mediaPlayer?.isPlaying == true) {
            mediaPlayer?.pause()
        }
    }

    //播放出错
    private fun mediaPlayError() {
        showToast("播放出错")
        mediaPlayer?.reset()
    }


    //播放按钮状态控制
    private fun playControlStatusSwitch(startPlay: Boolean) {

        if (startPlay) {
            startCoverAnim()
            mediaPlayStart()
        } else {
            stopCoverAnim()
            mediaPlayPause()
        }
        bindingFloatPlayer.ivPlayerControl.setImageResource(
            if (startPlay) R.drawable.ic_baseline_pause_24
            else R.drawable.ic_baseline_play_arrow_24
        )
        isPlaying = startPlay
    }

    //初始化旋转动画
    private fun initRotationAnimator(target: View) {
        //顺时针
        animCoverRotation = ObjectAnimator.ofFloat(target, "rotation", 0f, 360f)
        //3s一圈
        animCoverRotation.duration = 6000
        animCoverRotation.repeatMode = ValueAnimator.RESTART
        animCoverRotation.repeatCount = ValueAnimator.INFINITE
        animCoverRotation.interpolator = LinearInterpolator()
    }

    //开始播放
    private fun startCoverAnim() {

        if (animCoverRotation.isPaused) {
            animCoverRotation.resume()
        } else {
            animCoverRotation.start()
        }
    }

    //取消播放
    private fun stopCoverAnim() {
        animCoverRotation.pause()
    }

    /**
     * 封面点击切换状态
     * @param expansion 展开
     * */
    private fun playExpansionStatusSwitch(expansion: Boolean) {
        if (expansion == isExpansion) return
        if (expansion) playViewExpansion() else playViewShrink()
        isExpansion = expansion
    }

    //展开播放控件
    private fun playViewExpansion() {
        TransitionManager.beginDelayedTransition(bindingFloatPlayer.root)
        mCsReset.applyTo(bindingFloatPlayer.root)
    }

    //收缩播放控件
    private fun playViewShrink() {
        TransitionManager.beginDelayedTransition(bindingFloatPlayer.root)
        mCsApply.setVisibility(R.id.ivPlayerClose, View.GONE)
        mCsApply.setVisibility(R.id.ivPlayerNext, View.GONE)
        mCsApply.setVisibility(R.id.ivPlayerControl, View.GONE)
        mCsApply.applyTo(bindingFloatPlayer.root)
    }

    //更新播放器通知UI
    private fun updateNotification() {

        val notificationCompatAction = NotificationCompat.Action.Builder(
            if (playing()) R.drawable.ic_baseline_pause_24
            else R.drawable.ic_baseline_play_arrow_24,
            "switch",
            PendingIntent.getBroadcast(
                FloatApp.appContext,
                111,
                Intent(PlayerActionBroadCastReceiver.actionSwitch),
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
                    PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
                else PendingIntent.FLAG_UPDATE_CURRENT
            )
        ).build()

        val nextPendingIntent = PendingIntent.getBroadcast(
            FloatApp.appContext, 222,
            Intent(PlayerActionBroadCastReceiver.actionNext),
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
            else PendingIntent.FLAG_UPDATE_CURRENT
        )

        val notification =
            NotificationCompat.Builder(FloatApp.appContext, notificationChannelMedia)
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
                .setSmallIcon(R.mipmap.ic_launcher)
                .addAction(notificationCompatAction)
                .addAction(R.drawable.ic_baseline_skip_next_24, "next", nextPendingIntent)
                .setStyle(androidx.media.app.NotificationCompat.MediaStyle())
                .setContentTitle("这是标题")
                .setContentText("这是内容这是内容")
                .build()
        mNotificationManager.notify(notificationMediaId, notification)
    }

    //取消掉通知栏播放器
    private fun cancelNotificationMedia() {
        mNotificationManager.cancel(notificationMediaId)
    }
}

2、BaseActivity

import androidx.appcompat.app.AppCompatActivity

open class BaseActivity: AppCompatActivity() {

    override fun onResume() {
        super.onResume()
        FloatPlayer.getInstance().show(this)
    }

    override fun onPause() {
        super.onPause()
        FloatPlayer.getInstance().dismiss()
    }
}

三、通知栏播放器实现

原理:创建 NotificationCompat.MediaStyle() 样式的通知,需要引入依赖:

implementation 'androidx.media:media:1.3.0'

并且给通知添加Action,用来和页面的播放起实现操作的联动。

如何创建通知,如下:

//创建通知
    private fun createNotification() {

        val notificationCompatAction = NotificationCompat.Action.Builder(
            if (FloatPlayer.getInstance().playing()) R.drawable.ic_baseline_pause_24
            else R.drawable.ic_baseline_play_arrow_24,
            "switch",
            PendingIntent.getBroadcast(
                this,
                111,
                Intent(PlayerActionBroadCastReceiver.actionSwitch),
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
                    PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
                else PendingIntent.FLAG_UPDATE_CURRENT
            )
        ).build()

        val nextPendingIntent = PendingIntent.getBroadcast(
            this, 222,
            Intent(PlayerActionBroadCastReceiver.actionNext),
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
                PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
            else PendingIntent.FLAG_UPDATE_CURRENT
        )

        val notification = NotificationCompat.Builder(
            this,
            FloatPlayer.notificationChannelMedia
        )
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setSmallIcon(R.mipmap.ic_launcher)
            .addAction(notificationCompatAction)
            .addAction(R.drawable.ic_baseline_skip_next_24, "next", nextPendingIntent)
            .setStyle(androidx.media.app.NotificationCompat.MediaStyle())
            .setContentTitle("这是标题")
            .setContentText("这是内容这是内容")
            .setLargeIcon(BitmapFactory.decodeResource(resources, R.mipmap.ic_player_cover))
            .build()

        val notificationManager =
            getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            notificationManager.createNotificationChannel(
                NotificationChannel(
                    FloatPlayer.notificationChannelMedia,
                    "播放器", NotificationManager.IMPORTANCE_DEFAULT
                )
            )
        }
        notificationManager.notify(FloatPlayer.notificationMediaId, notification)
    }

四、通知栏播放器和浮窗播放器联动实现

实现原理:

1、播放器的操作通过更新同一个id的通知,去更新通知播放器的UI显示。

2、定义广播接收器:PlayerActionBroadCastReceiver,接收到对应操作的广播之后FloatPlayer相应操作,通知栏播放器的操作,在上边(三)中定义的Action当中的PendingIntent发送对应操作的广播。

广播接收器 PlayerActionBroadCastReceiver 如下:

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent

/**
 * 通知栏播放控制按钮通知播放器操作
 * */
class PlayerActionBroadCastReceiver : BroadcastReceiver() {

    companion object {

        const val actionSwitch = "floatPlayer.switch"
        const val actionNext = "floatPlayer.next"
    }

    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            actionSwitch -> {
                FloatPlayer.getInstance().playSwitch()
            }
            actionNext -> {
                FloatPlayer.getInstance().playNext()
            }
        }
    }
}

 页面注册广播

//注册控制接受receiver
    private fun registerPlayControlReceiver() {

        val intentFilter = IntentFilter()
        intentFilter.addAction(PlayerActionBroadCastReceiver.actionSwitch)
        intentFilter.addAction(PlayerActionBroadCastReceiver.actionNext)
        registerReceiver(FloatPlayer.getInstance().mPlayControlReceiver, intentFilter)
    }

页面取消注册广播

//取消注册receiver
    private fun cancelPlayControlReceiver() {
        unregisterReceiver(FloatPlayer.getInstance().mPlayControlReceiver)
    }

最后

播放器动画背景View,因为 ConstraintSet 通过显示,隐藏控件展现的动画显示在真机显示有瑕疵,所以自定义背景动画View,下边是PlayerBgView代码

import android.animation.TypeEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.interpolator.view.animation.FastOutSlowInInterpolator

/**
 * 播放器自定义的背景View:因为Constraint动画有显示瑕疵
 * */
class PlayerBgView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    //控件宽度
    private var mWidth = 0f

    //背景圆形半径(控件高度一半)
    private var mRadius = 0f

    //动画执行中的终点坐标
    private var mPositionEndX = 0f
    private var mPaint: Paint? = null

    //是否展开状态
    private var bExpend = true

    //背景颜色
    private val mColorBG = Color.parseColor("#D7D7D7")

    //动画时长
    private val mTimeAnim = 400L

    //展开后终点坐标x,固定
    private var mEndX = 0f

    init {
        initPaint()
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mWidth = w.toFloat()
        mRadius = h / 2f
        mPositionEndX = mWidth - mRadius
        mEndX = mPositionEndX
        measurePaintStrokeWidth(h.toFloat())
    }

    private fun initPaint() {
        mPaint = Paint()
        mPaint!!.isAntiAlias = true
        mPaint!!.style = Paint.Style.FILL
        mPaint!!.color = mColorBG
        mPaint!!.strokeCap = Paint.Cap.ROUND
    }

    //设置画笔宽度为视图高度
    private fun measurePaintStrokeWidth(height: Float) {
        mPaint?.strokeWidth = height
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        drawBg(canvas)
    }

    private fun drawBg(canvas: Canvas) {
        canvas.drawCircle(mRadius, mRadius, mRadius, mPaint!!)
        if (mPositionEndX == 0f) return
        canvas.drawLine(mRadius, mRadius, mPositionEndX, mRadius, mPaint!!)
    }

    fun doAnimation() {
        if (bExpend) doShrinkAnimation() else doExpandAnimation()
        bExpend = !bExpend
    }

    //展开动画
    private fun doExpandAnimation() {
        val animator = ValueAnimator.ofObject(PositiveEvaluator(), mRadius, mEndX)
        animator.addUpdateListener { valueAnimator: ValueAnimator ->
            mPositionEndX = valueAnimator.animatedValue as Float
            invalidate()
        }
        animator.duration = mTimeAnim
        animator.interpolator = FastOutSlowInInterpolator()
        animator.start()
    }

    private inner class PositiveEvaluator : TypeEvaluator<Float> {
        override fun evaluate(v: Float, startValue: Float, endValue: Float): Float {
            return startValue + v * (endValue - startValue)
        }
    }

    //收缩动画
    private fun doShrinkAnimation() {
        val animator = ValueAnimator.ofObject(NegativeEvaluator(), mRadius, mEndX)
        animator.addUpdateListener { valueAnimator: ValueAnimator ->
            mPositionEndX = valueAnimator.animatedValue as Float
            invalidate()
        }
        animator.duration = mTimeAnim
        animator.interpolator = FastOutSlowInInterpolator()
        animator.start()
    }

    private inner class NegativeEvaluator : TypeEvaluator<Float> {
        override fun evaluate(v: Float, startValue: Float, endValue: Float): Float {
            return endValue - v * (endValue - startValue)
        }
    }
}

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值