一、最终效果如图
二、音频播放器浮窗实现
原理:
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)
}
}
}