PlayerAndroid/app/src/main/java/code/name/monkey/retromusic/lyrics/LrcView.kt

714 lines
24 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
* Copyright (c) 2019 Hemanth Savarala.
*
* Licensed under the GNU General Public License v3
*
* This is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*/
package code.name.monkey.retromusic.lyrics
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Looper
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.ViewConfiguration
import android.view.animation.DecelerateInterpolator
import android.widget.OverScroller
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat
import code.name.monkey.retromusic.BuildConfig
import code.name.monkey.retromusic.R
import java.util.*
/**
* Desc : 歌词
* Author : Lauzy
* Date : 2017/10/13
* Blog : http://www.jianshu.com/u/e76853f863a9
* Email : freedompaladin@gmail.com
*/
class LrcView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var mLrcData: MutableList<Lrc>? = null
private var mTextPaint: TextPaint? = null
private var mDefaultContent: String? = null
private var mCurrentLine: Int = 0
private var mOffset: Float = 0.toFloat()
private var mLastMotionX: Float = 0.toFloat()
private var mLastMotionY: Float = 0.toFloat()
private var mScaledTouchSlop: Int = 0
private var mOverScroller: OverScroller? = null
private var mVelocityTracker: VelocityTracker? = null
private var mMaximumFlingVelocity: Int = 0
private var mMinimumFlingVelocity: Int = 0
private var mLrcTextSize: Float = 0.toFloat()
private var mLrcLineSpaceHeight: Float = 0.toFloat()
private var mTouchDelay: Int = 0
private var mNormalColor: Int = 0
private var mCurrentPlayLineColor: Int = 0
private var mNoLrcTextSize: Float = 0.toFloat()
private var mNoLrcTextColor: Int = 0
//是否拖拽中否的话响应onClick事件
private var isDragging: Boolean = false
//用户开始操作
private var isUserScroll: Boolean = false
private var isAutoAdjustPosition = true
private var mPlayDrawable: Drawable? = null
private var isShowTimeIndicator: Boolean = false
private var mPlayRect: Rect? = null
private var mIndicatorPaint: Paint? = null
private var mIndicatorLineWidth: Float = 0.toFloat()
private var mIndicatorTextSize: Float = 0.toFloat()
private var mCurrentIndicateLineTextColor: Int = 0
private var mIndicatorLineColor: Int = 0
private var mIndicatorMargin: Float = 0.toFloat()
private var mIconLineGap: Float = 0.toFloat()
private var mIconWidth: Float = 0.toFloat()
private var mIconHeight: Float = 0.toFloat()
private var isEnableShowIndicator = true
private var mIndicatorTextColor: Int = 0
private var mIndicatorTouchDelay: Int = 0
private val mLrcMap = HashMap<String, StaticLayout>()
private val mHideIndicatorRunnable = Runnable {
isShowTimeIndicator = false
invalidateView()
}
private val mStaticLayoutHashMap = HashMap<String, StaticLayout>()
private val mScrollRunnable = Runnable {
isUserScroll = false
scrollToPosition(mCurrentLine)
}
private var mOnPlayIndicatorLineListener: OnPlayIndicatorLineListener? = null
private val lrcWidth: Int
get() = width - paddingLeft - paddingRight
private val lrcHeight: Int
get() = height
private val isLrcEmpty: Boolean
get() = mLrcData == null || lrcCount == 0
private val lrcCount: Int
get() = mLrcData!!.size
//itemOffset 和 mOffset 最小即当前位置
val indicatePosition: Int
get() {
var pos = 0
var min = java.lang.Float.MAX_VALUE
for (i in mLrcData!!.indices) {
val offsetY = getItemOffsetY(i)
val abs = Math.abs(offsetY - mOffset)
if (abs < min) {
min = abs
pos = i
}
}
return pos
}
var playDrawable: Drawable?
get() = mPlayDrawable
set(playDrawable) {
mPlayDrawable = playDrawable
mPlayDrawable!!.bounds = mPlayRect!!
invalidateView()
}
init {
init(context, attrs)
}
private fun init(context: Context, attrs: AttributeSet?) {
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LrcView)
mLrcTextSize =
typedArray.getDimension(R.styleable.LrcView_lrcTextSize, sp2px(context, 15f).toFloat())
mLrcLineSpaceHeight = typedArray.getDimension(
R.styleable.LrcView_lrcLineSpaceSize,
dp2px(context, 20f).toFloat()
)
mTouchDelay = typedArray.getInt(R.styleable.LrcView_lrcTouchDelay, 3500)
mIndicatorTouchDelay = typedArray.getInt(R.styleable.LrcView_indicatorTouchDelay, 2500)
mNormalColor = typedArray.getColor(R.styleable.LrcView_lrcNormalTextColor, Color.GRAY)
mCurrentPlayLineColor =
typedArray.getColor(R.styleable.LrcView_lrcCurrentTextColor, Color.BLUE)
mNoLrcTextSize = typedArray.getDimension(
R.styleable.LrcView_noLrcTextSize,
dp2px(context, 20f).toFloat()
)
mNoLrcTextColor = typedArray.getColor(R.styleable.LrcView_noLrcTextColor, Color.BLACK)
mIndicatorLineWidth = typedArray.getDimension(
R.styleable.LrcView_indicatorLineHeight,
dp2px(context, 0.5f).toFloat()
)
mIndicatorTextSize = typedArray.getDimension(
R.styleable.LrcView_indicatorTextSize,
sp2px(context, 13f).toFloat()
)
mIndicatorTextColor =
typedArray.getColor(R.styleable.LrcView_indicatorTextColor, Color.GRAY)
mCurrentIndicateLineTextColor =
typedArray.getColor(R.styleable.LrcView_currentIndicateLrcColor, Color.GRAY)
mIndicatorLineColor =
typedArray.getColor(R.styleable.LrcView_indicatorLineColor, Color.GRAY)
mIndicatorMargin = typedArray.getDimension(
R.styleable.LrcView_indicatorStartEndMargin,
dp2px(context, 5f).toFloat()
)
mIconLineGap =
typedArray.getDimension(R.styleable.LrcView_iconLineGap, dp2px(context, 3f).toFloat())
mIconWidth = typedArray.getDimension(
R.styleable.LrcView_playIconWidth,
dp2px(context, 20f).toFloat()
)
mIconHeight = typedArray.getDimension(
R.styleable.LrcView_playIconHeight,
dp2px(context, 20f).toFloat()
)
mPlayDrawable = typedArray.getDrawable(R.styleable.LrcView_playIcon)
mPlayDrawable = if (mPlayDrawable == null) ContextCompat.getDrawable(
context,
R.drawable.ic_play_arrow_white_24dp
) else mPlayDrawable
typedArray.recycle()
setupConfigs(context)
}
private fun setupConfigs(context: Context) {
mScaledTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
mMaximumFlingVelocity = ViewConfiguration.get(context).scaledMaximumFlingVelocity
mMinimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity
mOverScroller = OverScroller(context, DecelerateInterpolator())
mOverScroller!!.setFriction(0.1f)
// ViewConfiguration.getScrollFriction(); 默认摩擦力 0.015f
mTextPaint = TextPaint()
mTextPaint!!.apply {
isAntiAlias = true
textAlign = Paint.Align.LEFT
textSize = mLrcTextSize
/* if (BuildConfig.FLAVOR != "nofont") {
typeface = ResourcesCompat.getFont(context, R.font.circular)
}*/
}
mDefaultContent = DEFAULT_CONTENT
mIndicatorPaint = Paint()
mIndicatorPaint!!.isAntiAlias = true
mIndicatorPaint!!.strokeWidth = mIndicatorLineWidth
mIndicatorPaint!!.color = mIndicatorLineColor
mPlayRect = Rect()
mIndicatorPaint!!.textSize = mIndicatorTextSize
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (changed) {
mPlayRect!!.left = mIndicatorMargin.toInt()
mPlayRect!!.top = (height / 2 - mIconHeight / 2).toInt()
mPlayRect!!.right = (mPlayRect!!.left + mIconWidth).toInt()
mPlayRect!!.bottom = (mPlayRect!!.top + mIconHeight).toInt()
mPlayDrawable!!.bounds = mPlayRect!!
}
}
fun setLrcData(lrcData: MutableList<Lrc>) {
resetView(DEFAULT_CONTENT)
mLrcData = lrcData
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (isLrcEmpty) {
drawEmptyText(canvas)
return
}
val indicatePosition = indicatePosition
mTextPaint!!.textSize = mLrcTextSize
mTextPaint!!.textAlign = Paint.Align.LEFT
var y = (lrcHeight / 2).toFloat()
val x = dip2px(context, 16f).toFloat()
for (i in 0 until lrcCount) {
if (i > 0) {
y += (getTextHeight(i - 1) + getTextHeight(i)) / 2 + mLrcLineSpaceHeight
}
if (mCurrentLine == i) {
mTextPaint!!.color = mCurrentPlayLineColor
} else if (indicatePosition == i && isShowTimeIndicator) {
mTextPaint!!.color = mCurrentIndicateLineTextColor
} else {
mTextPaint!!.color = mNormalColor
}
drawLrc(canvas, x, y, i)
}
if (isShowTimeIndicator) {
mPlayDrawable!!.draw(canvas)
val time = mLrcData!![indicatePosition].time
val timeWidth = mIndicatorPaint!!.measureText(LrcHelper.formatTime(time))
mIndicatorPaint!!.color = mIndicatorLineColor
canvas.drawLine(
mPlayRect!!.right + mIconLineGap, (height / 2).toFloat(),
width - timeWidth * 1.3f, (height / 2).toFloat(), mIndicatorPaint!!
)
val baseX = (width - timeWidth * 1.1f).toInt()
val baseline =
(height / 2).toFloat() - (mIndicatorPaint!!.descent() - mIndicatorPaint!!.ascent()) / 2 - mIndicatorPaint!!.ascent()
mIndicatorPaint!!.color = mIndicatorTextColor
canvas.drawText(
LrcHelper.formatTime(time),
baseX.toFloat(),
baseline,
mIndicatorPaint!!
)
}
}
private fun dip2px(context: Context, dpVale: Float): Int {
val scale = context.resources.displayMetrics.density
return (dpVale * scale + 0.5f).toInt()
}
private fun drawLrc(canvas: Canvas, x: Float, y: Float, i: Int) {
val text = mLrcData!![i].text
var staticLayout: StaticLayout? = mLrcMap[text]
if (staticLayout == null) {
mTextPaint!!.textSize = mLrcTextSize
staticLayout = StaticLayout(
text,
mTextPaint,
lrcWidth,
Layout.Alignment.ALIGN_NORMAL,
1f,
0f,
false
)
mLrcMap[text] = staticLayout
}
canvas.save()
canvas.translate(x, y - (staticLayout.height / 2).toFloat() - mOffset)
staticLayout.draw(canvas)
canvas.restore()
}
//中间空文字
private fun drawEmptyText(canvas: Canvas) {
mTextPaint!!.textAlign = Paint.Align.LEFT
mTextPaint!!.color = mNoLrcTextColor
mTextPaint!!.textSize = mNoLrcTextSize
canvas.save()
val staticLayout = StaticLayout(
mDefaultContent, mTextPaint,
lrcWidth, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false
)
val margin = dip2px(context, 16f).toFloat();
canvas.translate(margin, margin)
staticLayout.draw(canvas)
canvas.restore()
}
fun updateTime(time: Long) {
if (isLrcEmpty) {
return
}
val linePosition = getUpdateTimeLinePosition(time)
if (mCurrentLine != linePosition) {
mCurrentLine = linePosition
if (isUserScroll) {
invalidateView()
return
}
ViewCompat.postOnAnimation(this@LrcView, mScrollRunnable)
}
}
private fun getUpdateTimeLinePosition(time: Long): Int {
var linePos = 0
for (i in 0 until lrcCount) {
val lrc = mLrcData!![i]
if (time >= lrc.time) {
if (i == lrcCount - 1) {
linePos = lrcCount - 1
} else if (time < mLrcData!![i + 1].time) {
linePos = i
break
}
}
}
return linePos
}
private fun scrollToPosition(linePosition: Int) {
val scrollY = getItemOffsetY(linePosition)
val animator = ValueAnimator.ofFloat(mOffset, scrollY)
animator.addUpdateListener { animation ->
mOffset = animation.animatedValue as Float
invalidateView()
}
animator.duration = 300
animator.start()
}
private fun getItemOffsetY(linePosition: Int): Float {
var tempY = 0f
for (i in 1..linePosition) {
tempY += (getTextHeight(i - 1) + getTextHeight(i)) / 2 + mLrcLineSpaceHeight
}
return tempY
}
private fun getTextHeight(linePosition: Int): Float {
val text = mLrcData!![linePosition].text
var staticLayout: StaticLayout? = mStaticLayoutHashMap[text]
if (staticLayout == null) {
mTextPaint!!.textSize = mLrcTextSize
staticLayout = StaticLayout(
text, mTextPaint,
lrcWidth, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false
)
mStaticLayoutHashMap[text] = staticLayout
}
return staticLayout.height.toFloat()
}
private fun overScrolled(): Boolean {
return mOffset > getItemOffsetY(lrcCount - 1) || mOffset < 0
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isLrcEmpty) {
return super.onTouchEvent(event)
}
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain()
}
mVelocityTracker!!.addMovement(event)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
removeCallbacks(mScrollRunnable)
removeCallbacks(mHideIndicatorRunnable)
if (!mOverScroller!!.isFinished) {
mOverScroller!!.abortAnimation()
}
mLastMotionX = event.x
mLastMotionY = event.y
isUserScroll = true
isDragging = false
}
MotionEvent.ACTION_MOVE -> {
var moveY = event.y - mLastMotionY
if (Math.abs(moveY) > mScaledTouchSlop) {
isDragging = true
isShowTimeIndicator = isEnableShowIndicator
}
if (isDragging) {
// if (mOffset < 0) {
// mOffset = Math.max(mOffset, -getTextHeight(0) - mLrcLineSpaceHeight);
// }
val maxHeight = getItemOffsetY(lrcCount - 1)
// if (mOffset > maxHeight) {
// mOffset = Math.min(mOffset, maxHeight + getTextHeight(getLrcCount() - 1) + mLrcLineSpaceHeight);
// }
if (mOffset < 0 || mOffset > maxHeight) {
moveY /= 3.5f
}
mOffset -= moveY
mLastMotionY = event.y
invalidateView()
}
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
if (!isDragging && (!isShowTimeIndicator || !onClickPlayButton(event))) {
isShowTimeIndicator = false
invalidateView()
performClick()
}
handleActionUp(event)
}
}
// return isDragging || super.onTouchEvent(event);
return true
}
private fun handleActionUp(event: MotionEvent) {
if (isEnableShowIndicator) {
ViewCompat.postOnAnimationDelayed(
this@LrcView,
mHideIndicatorRunnable,
mIndicatorTouchDelay.toLong()
)
}
if (isShowTimeIndicator && mPlayRect != null && onClickPlayButton(event)) {
isShowTimeIndicator = false
invalidateView()
if (mOnPlayIndicatorLineListener != null) {
mOnPlayIndicatorLineListener!!.onPlay(
mLrcData!![indicatePosition].time,
mLrcData!![indicatePosition].text
)
}
}
if (overScrolled() && mOffset < 0) {
scrollToPosition(0)
if (isAutoAdjustPosition) {
ViewCompat.postOnAnimationDelayed(
this@LrcView,
mScrollRunnable,
mTouchDelay.toLong()
)
}
return
}
if (overScrolled() && mOffset > getItemOffsetY(lrcCount - 1)) {
scrollToPosition(lrcCount - 1)
if (isAutoAdjustPosition) {
ViewCompat.postOnAnimationDelayed(
this@LrcView,
mScrollRunnable,
mTouchDelay.toLong()
)
}
return
}
mVelocityTracker!!.computeCurrentVelocity(1000, mMaximumFlingVelocity.toFloat())
val YVelocity = mVelocityTracker!!.yVelocity
val absYVelocity = Math.abs(YVelocity)
if (absYVelocity > mMinimumFlingVelocity) {
mOverScroller!!.fling(
0, mOffset.toInt(), 0, (-YVelocity).toInt(), 0,
0, 0, getItemOffsetY(lrcCount - 1).toInt(),
0, getTextHeight(0).toInt()
)
invalidateView()
}
releaseVelocityTracker()
if (isAutoAdjustPosition) {
ViewCompat.postOnAnimationDelayed(this@LrcView, mScrollRunnable, mTouchDelay.toLong())
}
}
private fun onClickPlayButton(event: MotionEvent): Boolean {
val left = mPlayRect!!.left.toFloat()
val right = mPlayRect!!.right.toFloat()
val top = mPlayRect!!.top.toFloat()
val bottom = mPlayRect!!.bottom.toFloat()
val x = event.x
val y = event.y
return (mLastMotionX > left && mLastMotionX < right && mLastMotionY > top
&& mLastMotionY < bottom && x > left && x < right && y > top && y < bottom)
}
override fun computeScroll() {
super.computeScroll()
if (mOverScroller!!.computeScrollOffset()) {
mOffset = mOverScroller!!.currY.toFloat()
invalidateView()
}
}
private fun releaseVelocityTracker() {
if (null != mVelocityTracker) {
mVelocityTracker!!.clear()
mVelocityTracker!!.recycle()
mVelocityTracker = null
}
}
fun resetView(defaultContent: String) {
if (mLrcData != null) {
mLrcData!!.clear()
}
mLrcMap.clear()
mStaticLayoutHashMap.clear()
mCurrentLine = 0
mOffset = 0f
isUserScroll = false
isDragging = false
mDefaultContent = defaultContent
removeCallbacks(mScrollRunnable)
invalidate()
}
override fun performClick(): Boolean {
return super.performClick()
}
fun dp2px(context: Context, dpVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dpVal, context.resources.displayMetrics
).toInt()
}
fun sp2px(context: Context, spVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
spVal, context.resources.displayMetrics
).toInt()
}
/**
* 暂停(手动滑动歌词后,不再自动回滚至当前播放位置)
*/
fun pause() {
isAutoAdjustPosition = false
invalidateView()
}
/**
* 恢复(继续自动回滚)
*/
fun resume() {
isAutoAdjustPosition = true
ViewCompat.postOnAnimationDelayed(this@LrcView, mScrollRunnable, mTouchDelay.toLong())
invalidateView()
}
/*------------------Config-------------------*/
private fun invalidateView() {
if (Looper.getMainLooper().thread === Thread.currentThread()) {
invalidate()
} else {
postInvalidate()
}
}
fun setOnPlayIndicatorLineListener(onPlayIndicatorLineListener: OnPlayIndicatorLineListener) {
mOnPlayIndicatorLineListener = onPlayIndicatorLineListener
}
fun setEmptyContent(defaultContent: String) {
mDefaultContent = defaultContent
invalidateView()
}
fun setLrcTextSize(lrcTextSize: Float) {
mLrcTextSize = lrcTextSize
invalidateView()
}
fun setLrcLineSpaceHeight(lrcLineSpaceHeight: Float) {
mLrcLineSpaceHeight = lrcLineSpaceHeight
invalidateView()
}
fun setTouchDelay(touchDelay: Int) {
mTouchDelay = touchDelay
invalidateView()
}
fun setNormalColor(@ColorInt normalColor: Int) {
mNormalColor = normalColor
invalidateView()
}
fun setCurrentPlayLineColor(@ColorInt currentPlayLineColor: Int) {
mCurrentPlayLineColor = currentPlayLineColor
invalidateView()
}
fun setNoLrcTextSize(noLrcTextSize: Float) {
mNoLrcTextSize = noLrcTextSize
invalidateView()
}
fun setNoLrcTextColor(@ColorInt noLrcTextColor: Int) {
mNoLrcTextColor = noLrcTextColor
invalidateView()
}
fun setIndicatorLineWidth(indicatorLineWidth: Float) {
mIndicatorLineWidth = indicatorLineWidth
invalidateView()
}
fun setIndicatorTextSize(indicatorTextSize: Float) {
// mIndicatorTextSize = indicatorTextSize;
mIndicatorPaint!!.textSize = indicatorTextSize
invalidateView()
}
fun setCurrentIndicateLineTextColor(currentIndicateLineTextColor: Int) {
mCurrentIndicateLineTextColor = currentIndicateLineTextColor
invalidateView()
}
fun setIndicatorLineColor(indicatorLineColor: Int) {
mIndicatorLineColor = indicatorLineColor
invalidateView()
}
fun setIndicatorMargin(indicatorMargin: Float) {
mIndicatorMargin = indicatorMargin
invalidateView()
}
fun setIconLineGap(iconLineGap: Float) {
mIconLineGap = iconLineGap
invalidateView()
}
fun setIconWidth(iconWidth: Float) {
mIconWidth = iconWidth
invalidateView()
}
fun setIconHeight(iconHeight: Float) {
mIconHeight = iconHeight
invalidateView()
}
fun setEnableShowIndicator(enableShowIndicator: Boolean) {
isEnableShowIndicator = enableShowIndicator
invalidateView()
}
fun setIndicatorTextColor(indicatorTextColor: Int) {
mIndicatorTextColor = indicatorTextColor
invalidateView()
}
interface OnPlayIndicatorLineListener {
fun onPlay(time: Long, content: String)
}
companion object {
private const val DEFAULT_CONTENT = "Empty"
}
}