diff --git a/app/build.gradle b/app/build.gradle index 3e5de3f..7245777 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,7 +19,7 @@ android { //noinspection ExpiredTargetSdkVersion,OldTargetApi targetSdk 31 versionCode 1 - versionName "2.0.0" + versionName "2.1.0" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/app/src/main/java/com/dirror/lyricviewx/ILyricViewX.kt b/app/src/main/java/com/dirror/lyricviewx/ILyricViewX.kt new file mode 100644 index 0000000..d0422ee --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/ILyricViewX.kt @@ -0,0 +1,257 @@ +package com.dirror.lyricviewx + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.Layout +import androidx.annotation.ColorInt +import androidx.annotation.FloatRange +import androidx.annotation.Px +import java.io.File + +const val GRAVITY_CENTER = 0 // 居中 +const val GRAVITY_LEFT = 1 // 左 +const val GRAVITY_RIGHT = 2 // 右 + +fun Int.toLayoutAlign(): Layout.Alignment { + return when (this) { + GRAVITY_LEFT -> Layout.Alignment.ALIGN_NORMAL + GRAVITY_CENTER -> Layout.Alignment.ALIGN_CENTER + GRAVITY_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_CENTER + } +} + +/** + * LyricViewX 接口 + * 从 LyricViewX 提取,方便管理 + * + * @author Moriafly + * @since 2021年1月28日16:29:16 + */ +interface LyricViewXInterface { + + /** + * 设置整句之间的间隔高度 + * @param height px + */ + fun setSentenceDividerHeight(@Px height: Float) + + /** + * 设置原句与翻译之间的间隔高度 + * @param height px + */ + fun setTranslateDividerHeight(@Px height: Float) + + /** + * 设置歌词整体的垂直偏移值,配合[setHorizontalOffsetPercent]使用 + * @param offset px + * + * @see [setHorizontalOffsetPercent] + */ + fun setHorizontalOffset(@Px offset: Float) + + /** + * 设置歌词整体的垂直偏移,相对于控件高度的百分比,0.5f即表示居中,配合[setHorizontalOffset]使用 + * + * @param percent 0.0f ~ 1.0f + * + * @see [setHorizontalOffset] + */ + fun setHorizontalOffsetPercent(@FloatRange(from = 0.0, to = 1.0) percent: Float) + + /** + * 设置翻译相对与原词之间的缩放比例值 + * @param scaleValue 一般来说 0.8f 是个不错的值 + */ + fun setTranslateTextScaleValue(@FloatRange(from = 0.1, to = 2.0) scaleValue: Float) + + /** + * 设置文字的对齐方向 + */ + fun setTextGravity(gravity: Int) + + /** + * 设置非当前行歌词字体颜色 [normalColor] + */ + fun setNormalColor(@ColorInt normalColor: Int) + + /** + * 普通歌词文本字体大小 [size],单位 px + */ + fun setNormalTextSize(@Px size: Float) + + /** + * 当前歌词文本字体大小 + */ + fun setCurrentTextSize(size: Float) + + /** + * 设置当前行歌词的字体颜色 + */ + fun setCurrentColor(currentColor: Int) + + /** + * 设置拖动歌词时选中歌词的字体颜色 + */ + fun setTimelineTextColor(timelineTextColor: Int) + + /** + * 设置拖动歌词时时间线的颜色 + */ + fun setTimelineColor(timelineColor: Int) + + /** + * 设置拖动歌词时右侧时间字体颜色 + */ + fun setTimeTextColor(timeTextColor: Int) + + /** + * 设置歌词为空时屏幕中央显示的文字 [label],如“暂无歌词” + */ + fun setLabel(label: String) + + /** + * 加载歌词文本 + * 两种语言的歌词时间戳需要一致 + * + * @param mainLyricText 第一种语言歌词文本 + * @param secondLyricText 可选,第二种语言歌词文本 + */ + fun loadLyric(mainLyricText: String?, secondLyricText: String? = null) + + /** + * 加载歌词 [LyricEntry] 集合 + * 如果你在 Service 等地方自行解析歌词包装成 [LyricEntry] 集合,那么可以使用此方法载入歌词 + * + * @param lyricEntries 歌词集合 + * @since 1.3.1 + */ + fun loadLyric(lyricEntries: List) + + /** + * 刷新歌词 + * + * @param time 当前播放时间 + */ + fun updateTime(time: Long, force: Boolean = false) + + /** + * 设置歌词是否允许拖动 + * + * @param draggable 是否允许拖动 + * @param onPlayClickListener 设置歌词拖动后播放按钮点击监听器,如果允许拖动,则不能为 null + */ + fun setDraggable(draggable: Boolean, onPlayClickListener: OnPlayClickListener?) + + /** + * 设置单击 + */ + fun setOnSingerClickListener(onSingerClickListener: OnSingleClickListener?) + + /** + * 获取当前歌词每句实体,可用于歌词分享 + * + * @return LyricEntry 集合 + */ + fun getLyricEntryList(): List + + /** + * 设置当前歌词每句实体 + */ + fun setLyricEntryList(newList: List) + + /** + * 获取当前行歌词 + */ + fun getCurrentLineLyricEntry(): LyricEntry? + + /** + * 为歌词设置自定义的字体 + * + * @param file 字体文件 + */ + fun setLyricTypeface(file: File) + + /** + * 为歌词设置自定义的字体 + * + * @param path 字体文件路径 + */ + fun setLyricTypeface(path: String) + + /** + * 为歌词设置自定义的字体,可为空,若为空则应清除字体 + * + * @param typeface 字体对象 + */ + fun setLyricTypeface(typeface: Typeface?) + + /** + * 为歌词的过渡动画设置阻尼比(数值越大,回弹次数越多) + * + * @param dampingRatio 阻尼比 详见[androidx.dynamicanimation.animation.SpringForce] + */ + fun setDampingRatioForLyric(dampingRatio: Float) + + /** + * 为歌词视图的滚动动画设置阻尼比(数值越大,回弹次数越多) + * + * @param dampingRatio 阻尼比 详见[androidx.dynamicanimation.animation.SpringForce] + */ + fun setDampingRatioForViewPort(dampingRatio: Float) + + /** + * 为歌词的过渡动画设置刚度(数值越大,动画越短) + * + * @param stiffness 刚度 详见[androidx.dynamicanimation.animation.SpringForce] + */ + fun setStiffnessForLyric(stiffness: Float) + + /** + * 为歌词视图的滚动动画设置刚度(数值越大,动画越短) + * + * @param stiffness 刚度 详见[androidx.dynamicanimation.animation.SpringForce] + */ + fun setStiffnessForViewPort(stiffness: Float) + + /** + * 设置跳转播放按钮 + */ + fun setPlayDrawable(drawable: Drawable) + + /** + * 设置是否绘制歌词翻译 + */ + fun setIsDrawTranslation(isDrawTranslation: Boolean) + + /** + * 是否开启特定的模糊效果 + */ + fun setIsEnableBlurEffect(isEnableBlurEffect: Boolean) + + /** + * 设置元素的偏移百分比,0.5f即表示居中 + * + * @param itemOffsetPercent 0f ~ 1f 偏移百分比 + */ + fun setItemOffsetPercent(@FloatRange(from = 0.0, to = 1.0) itemOffsetPercent: Float) +} + +/** + * 播放按钮点击监听器,点击后应该跳转到指定播放位置 + */ +interface OnPlayClickListener { + /** + * 播放按钮被点击,应该跳转到指定播放位置 + * + * @return 是否成功消费该事件,如果成功消费,则会更新UI + */ + fun onPlayClick(time: Long): Boolean +} + +/** + * 点击歌词布局 + */ +interface OnSingleClickListener { + fun onClick() +} \ No newline at end of file diff --git a/app/src/main/java/com/dirror/lyricviewx/LyricEntry.kt b/app/src/main/java/com/dirror/lyricviewx/LyricEntry.kt new file mode 100644 index 0000000..b92499d --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/LyricEntry.kt @@ -0,0 +1,82 @@ +package com.dirror.lyricviewx + +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint + +/** + * 一行歌词实体 + * @since 2021年1月19日09:51:40 Moriafly 基于 LrcEntry 改造,转换为 kt ,移除部分过时方法 + * @param time 歌词时间 + * @param text 歌词文本 + */ +class LyricEntry(@JvmField val time: Long, @JvmField val text: String) : Comparable { + + /** + * 第二文本 + */ + @JvmField + var secondText: String? = null + + /** + * staticLayout + */ + var staticLayout: StaticLayout? = null + private set + var secondStaticLayout: StaticLayout? = null + private set + + @Deprecated("存在不显示翻译的情况,会导致offset发生改变,故不再固定存储offset") + /** + * 歌词距离视图顶部的距离 + */ + var offset = Float.MIN_VALUE + + /** + * 初始化 + * @param textPaint 文本画笔 + * @param width 宽度 + * @param align 位置 + */ + fun init( + textPaint: TextPaint, + secondTextPaint: TextPaint, + width: Int, align: Layout.Alignment + ) { + staticLayout = createStaticLayout(text, textPaint, width, align) + secondStaticLayout = createStaticLayout(secondText, secondTextPaint, width, align) + offset = Float.MIN_VALUE + } + + /** + * 继承 Comparable 比较 + * @param other LyricEntry + * @return 时间差 + */ + override fun compareTo(other: LyricEntry): Int { + return (time - other.time).toInt() + } + + companion object { + fun createStaticLayout( + text: String?, + paint: TextPaint, + width: Number, + align: Layout.Alignment + ): StaticLayout? { + if (text.isNullOrEmpty()) return null + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + StaticLayout.Builder + .obtain(text, 0, text.length, paint, width.toInt()) + .setAlignment(align) + .setLineSpacing(0f, 1f) + .setIncludePad(false) + .build() + } else { + StaticLayout(text, paint, width.toInt(), align, 1f, 0f, false) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/dirror/lyricviewx/LyricUtil.kt b/app/src/main/java/com/dirror/lyricviewx/LyricUtil.kt new file mode 100644 index 0000000..cb3b4b4 --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/LyricUtil.kt @@ -0,0 +1,289 @@ +package com.dirror.lyricviewx + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.graphics.Rect +import android.text.TextUtils +import android.text.format.DateUtils +import android.view.MotionEvent +import java.io.* +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.regex.Pattern + +/** + * 工具类 + * 原 LrcUtils 转 Kotlin + */ +object LyricUtil { + + private val PATTERN_LINE = Pattern.compile("((\\[\\d\\d:\\d\\d\\.\\d{2,3}])+)(.+)") + private val PATTERN_TIME = Pattern.compile("\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})]") + private val argbEvaluator = ArgbEvaluator() + + /** + * 从文件解析双语歌词 + */ + fun parseLrc(lrcFiles: Array?): List? { + if (lrcFiles == null || lrcFiles.size != 2 || lrcFiles[0] == null) { + return null + } + val mainLrcFile = lrcFiles[0] + val secondLrcFile = lrcFiles[1] + val mainEntryList = parseLrc(mainLrcFile) + val secondEntryList = parseLrc(secondLrcFile) + if (mainEntryList != null && secondEntryList != null) { + for (mainEntry in mainEntryList) { + for (secondEntry in secondEntryList) { + if (mainEntry.time == secondEntry.time) { + mainEntry.secondText = secondEntry.text + } + } + } + } + return mainEntryList + } + + /** + * 从文件解析歌词 + */ + private fun parseLrc(lrcFile: File?): List? { + if (lrcFile == null || !lrcFile.exists()) { + return null + } + val entryList: MutableList = ArrayList() + try { + val br = + BufferedReader(InputStreamReader(FileInputStream(lrcFile), StandardCharsets.UTF_8)) + var line: String + while (br.readLine().also { line = it } != null) { + val list = parseLine(line) + if (list != null && list.isNotEmpty()) { + entryList.addAll(list) + } + } + br.close() + } catch (e: IOException) { + e.printStackTrace() + } + entryList.sort() + return entryList + } + + /** + * 从文本解析双语歌词 + */ + fun parseLrc(lrcTexts: Array?): List? { + if (lrcTexts == null || lrcTexts.size != 2 || TextUtils.isEmpty(lrcTexts[0])) { + return null + } + val mainLrcText = lrcTexts[0] + val secondLrcText = lrcTexts[1] + val mainEntryList = mainLrcText?.let { parseLrc(it) } + + /** + * 当输入的secondLrcText为空时,按如下格式解析歌词 + * (音乐标签下载的第二种歌词格式) + * + * [00:21.11]いつも待ち合わせより15分前集合 + * [00:21.11]总会比相约时间早15分钟集合 + * [00:28.32]駅の改札ぬける + * [00:28.32]穿过车站的检票口 + * [00:31.39]ざわめきにわくわくだね + * [00:31.39]嘈杂声令内心兴奋不已 + * [00:35.23]どこへ向かうかなんて + * [00:35.23]不在意接下来要去哪里 + */ + if (TextUtils.isEmpty(secondLrcText)) { + var lastEntry: LyricEntry? = null + return mainEntryList?.filter { now -> + if (lastEntry == null) { + lastEntry = now + return@filter true + } + + if (lastEntry!!.time == now.time) { + lastEntry!!.secondText = now.text + lastEntry = null + return@filter false + } + + lastEntry = now + true + } + } + + val secondEntryList = secondLrcText?.let { parseLrc(it) } + if (mainEntryList != null && secondEntryList != null) { + for (mainEntry in mainEntryList) { + for (secondEntry in secondEntryList) { + if (mainEntry.time == secondEntry.time) { + mainEntry.secondText = secondEntry.text + } + } + } + } + return mainEntryList + } + + /** + * 从文本解析歌词 + */ + private fun parseLrc(lrcText: String): List? { + var lyricText = lrcText.trim() + if (TextUtils.isEmpty(lyricText)) return null + + if (lyricText.startsWith("\uFEFF")) { + lyricText = lyricText.replace("\uFEFF", "") + } + + // 针对传入 Language="Media Monkey Format"; Lyrics="......"; 的情况 + lyricText = lyricText.substringAfter("Lyrics=\"") + .substringBeforeLast("\";") + + val entryList: MutableList = ArrayList() + val array = lyricText.split("\\n".toRegex()).toTypedArray() + for (line in array) { + val list = parseLine(line) + if (!list.isNullOrEmpty()) { + entryList.addAll(list) + } + } + entryList.sort() + return entryList + } + + /** + * 获取网络文本,需要在工作线程中执行 + */ + fun getContentFromNetwork(url: String?, charset: String?): String? { + var lrcText: String? = null + try { + val url = URL(url) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10000 + conn.readTimeout = 10000 + if (conn.responseCode == 200) { + val `is` = conn.inputStream + val bos = ByteArrayOutputStream() + val buffer = ByteArray(1024) + var len: Int + while (`is`.read(buffer).also { len = it } != -1) { + bos.write(buffer, 0, len) + } + `is`.close() + bos.close() + lrcText = bos.toString(charset) + } + } catch (e: Exception) { + e.printStackTrace() + } + return lrcText + } + + /** + * 解析一行歌词 + */ + private fun parseLine(line: String): List? { + var lyricLine = line + if (TextUtils.isEmpty(lyricLine)) { + return null + } + lyricLine = lyricLine.trim { it <= ' ' } + // [00:17.65]让我掉下眼泪的 + val lineMatcher = PATTERN_LINE.matcher(lyricLine) + if (!lineMatcher.matches()) { + return null + } + val times = lineMatcher.group(1)!! + val text = lineMatcher.group(3)!! + val entryList: MutableList = ArrayList() + + // [00:17.65] + val timeMatcher = PATTERN_TIME.matcher(times) + while (timeMatcher.find()) { + val min = timeMatcher.group(1)!!.toLong() + val sec = timeMatcher.group(2)!!.toLong() + val milString = timeMatcher.group(3)!! + var mil = milString.toLong() + // 如果毫秒是两位数,需要乘以 10,when 新增支持 1 - 6 位毫秒,很多获取的歌词存在不同的毫秒位数 + when (milString.length) { + 1 -> mil *= 100 + 2 -> mil *= 10 + 4 -> mil /= 10 + 5 -> mil /= 100 + 6 -> mil /= 1000 + } + val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + entryList.add(LyricEntry(time, text)) + } + return entryList + } + + /** + * 转为[分:秒] + */ + fun formatTime(milli: Long): String { + val m = (milli / DateUtils.MINUTE_IN_MILLIS).toInt() + val s = (milli / DateUtils.SECOND_IN_MILLIS % 60).toInt() + val mm = String.format(Locale.getDefault(), "%02d", m) + val ss = String.format(Locale.getDefault(), "%02d", s) + return "$mm:$ss" + } + + /** + * BUG java.lang.NoSuchFieldException: No field sDurationScale in class Landroid/animation/ValueAnimator; #3 + */ + @SuppressLint("SoonBlockedPrivateApi") + @Deprecated("") + fun resetDurationScale() { + try { + val mField = ValueAnimator::class.java.getDeclaredField("sDurationScale") + mField.isAccessible = true + mField.setFloat(null, 1f) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * 结合fraction,计算两个值之间的比例 + */ + fun calcScaleValue(a: Float, b: Float, f: Float, reverse: Boolean = false): Float { + if (b == 0f) return 1f + return 1f + ((a - b) / b) * (if (reverse) 1f - f else f) + } + + /** + * 颜色值插值函数 + */ + fun lerpColor(a: Int, b: Int, f: Float): Int { + return argbEvaluator.evaluate(f, a, b) as Int + } + + /** + * 简单的插值函数 + */ + fun lerp(from: Float, to: Float, fraction: Float): Float { + return from + (to - from) * fraction + } + + /** + * 判断MotionEvent是否发生在Rect中 + */ + fun MotionEvent.insideOf(rect: Rect?): Boolean { + rect ?: return false + return rect.contains(x.toInt(), y.toInt()) + } + + fun normalize(min: Float, max: Float, value: Float, limit: Boolean = false): Float { + if (min == max) return 1f + return ((value - min) / (max - min)).let { + if (limit) it.coerceIn(0f, 1f) else it + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dirror/lyricviewx/LyricViewX.kt b/app/src/main/java/com/dirror/lyricviewx/LyricViewX.kt new file mode 100644 index 0000000..7c0cd01 --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/LyricViewX.kt @@ -0,0 +1,1097 @@ +package com.dirror.lyricviewx +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.os.Looper +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.text.format.DateUtils +import android.util.AttributeSet +import android.view.GestureDetector +import android.view.GestureDetector.SimpleOnGestureListener +import android.view.MotionEvent +import android.widget.Scroller +import androidx.annotation.FloatRange +import androidx.core.content.ContextCompat +import androidx.dynamicanimation.animation.SpringForce +import androidx.dynamicanimation.animation.springAnimationOf +import androidx.dynamicanimation.animation.withSpringForceProperties +import com.dirror.lyricviewx.LyricUtil.calcScaleValue +import com.dirror.lyricviewx.LyricUtil.formatTime +import com.dirror.lyricviewx.LyricUtil.insideOf +import com.dirror.lyricviewx.LyricUtil.lerp +import com.dirror.lyricviewx.LyricUtil.lerpColor +import com.dirror.lyricviewx.LyricUtil.normalize +import com.dirror.lyricviewx.extension.BlurMaskFilterExt +import com.lalilu.easeview.EaseView +import com.lalilu.easeview.animatevalue.BoolValue +import com.lalilu.easeview.animatevalue.FloatListAnimateValue +import com.muqingbfq.R +import java.io.File +import kotlin.concurrent.thread +import kotlin.math.abs +import kotlin.math.max +open class LyricViewX : EaseView, LyricViewXInterface { + constructor(context: Context) : super(context) { + init(null) + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + init(attrs) + } + + companion object { + private const val TAG = "LyricViewX" + // 时间线持续时间 + private const val TIMELINE_KEEP_TIME = 3 * DateUtils.SECOND_IN_MILLIS + + /** 单句歌词集合 */ + @JvmField + val lyricEntryList: MutableList = ArrayList() + + @JvmStatic + fun lrc(a: String?, b: String?) { + val lrcEntries = LyricUtil.parseLrc(arrayOf(a, b)) + if (!lrcEntries.isNullOrEmpty()) { + lyricEntryList.addAll(lrcEntries) + } + lyricEntryList.sort() + } + } + + private val readyHelper = ReadyHelper() + private val blurMaskFilterExt = BlurMaskFilterExt() + + + /** 主歌词画笔 */ + private val lyricPaint = TextPaint() + + /** 副歌词(一般为翻译歌词)画笔 */ + private val secondLyricPaint = TextPaint() + + /** 时间文字画笔 */ + private val timePaint = TextPaint() + + private var timeFontMetrics: Paint.FontMetrics? = null + + /** 跳转播放按钮 */ + private var playDrawable: Drawable? = null + + private var translateDividerHeight = 0f + private var sentenceDividerHeight = 0f + private var animationDuration: Long = 0 + private var normalTextColor = 0 + private var normalTextSize = 0f + private var currentTextColor = 0 + private var currentTextSize = 0f + private var translateTextScaleValue = 1f + private var timelineTextColor = 0 + private var timelineColor = 0 + private var timeTextColor = 0 + private var drawableWidth = 0 + private var timeTextWidth = 0 + private var defaultLabel: String? = null + private var lrcPadding = 0f + private var onPlayClickListener: OnPlayClickListener? = null + private var onSingerClickListener: OnSingleClickListener? = null + private var animator: ValueAnimator? = null + private var gestureDetector: GestureDetector? = null + private var scroller: Scroller? = null + private var flag: Any? = null + private var isTouching = false + private var isFling = false + private var textGravity = GRAVITY_CENTER // 歌词显示位置,靠左 / 居中 / 靠右 + private var horizontalOffset: Float = 0f + private var horizontalOffsetPercent: Float = 0.5f + private var itemOffsetPercent: Float = 0.5f + private var dampingRatioForLyric: Float = SpringForce.DAMPING_RATIO_LOW_BOUNCY + private var dampingRatioForViewPort: Float = SpringForce.DAMPING_RATIO_NO_BOUNCY + private var stiffnessForLyric: Float = SpringForce.STIFFNESS_LOW + private var stiffnessForViewPort: Float = SpringForce.STIFFNESS_VERY_LOW + + private var currentLine = 0 // 当前高亮显示的歌词 + private val focusLine: Int // 当前焦点歌词 + get() = if (isTouching || isFling) centerLine else currentLine + + /** + * 获取当前在视图中央的行数 + */ + private val centerLine: Int + get() { + var centerLine = 0 + var minDistance = Float.MAX_VALUE + var tempDistance: Float + + for (i in lyricEntryList.indices) { + tempDistance = abs(mViewPortOffset - getOffset(i)) + if (tempDistance < minDistance) { + minDistance = tempDistance + centerLine = i + } + } + return centerLine + } + + /** + * 获取歌词宽度 + */ + open val lrcWidth: Float + get() = width - lrcPadding * 2 + + /** + * 歌词整体的垂直偏移值 + */ + open val startOffset: Float + get() = height.toFloat() * horizontalOffsetPercent + horizontalOffset + + + /** + * 原有的mOffset被拆分成两个独立的offset,这样可以更好地让进度和拖拽滚动独立开来 + */ + private var mCurrentOffset = 0f // 实际的歌词进度Offset + private var mViewPortOffset = 0f // 歌词显示窗口的Offset + + private var animateProgress = 0f // 动画进度 + private var animateTargetOffset = 0f // 动画目标Offset + private var animateStartOffset = 0f // 动画起始Offset + + private val viewPortSpringAnimator = springAnimationOf( + getter = { mViewPortOffset }, + setter = { value -> + if (!isShowTimeline.value && !isTouching && !isFling) { + mViewPortOffset = value + invalidate() + } + } + ).withSpringForceProperties { + dampingRatio = dampingRatioForViewPort + stiffness = stiffnessForViewPort + finalPosition = 0f + } + + /** + * 弹性动画Scroller + */ + private val progressSpringAnimator = springAnimationOf( + getter = { mCurrentOffset }, + setter = { value -> + animateProgress = normalize(animateStartOffset, animateTargetOffset, value) + mCurrentOffset = value + + if (!isShowTimeline.value && !isTouching && !isFling) { + viewPortSpringAnimator.animateToFinalPosition(animateTargetOffset) + } + invalidate() + } + ).withSpringForceProperties { + dampingRatio = dampingRatioForLyric + stiffness = stiffnessForLyric + finalPosition = 0f + } + + @SuppressLint("CustomViewStyleable") + private fun init(attrs: AttributeSet?) { + readyHelper.readyState = STATE_INITIALIZING + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LyricView) + currentTextSize = typedArray.getDimension(R.styleable.LyricView_lrcTextSize, resources.getDimension(R.dimen.lrc_text_size)) + normalTextSize = typedArray.getDimension(R.styleable.LyricView_lrcNormalTextSize, resources.getDimension(R.dimen.lrc_text_size)) + if (normalTextSize == 0f) { + normalTextSize = currentTextSize + } + + sentenceDividerHeight = + typedArray.getDimension(R.styleable.LyricView_lrcSentenceDividerHeight, resources.getDimension(R.dimen.lrc_sentence_divider_height)) + translateDividerHeight = + typedArray.getDimension(R.styleable.LyricView_lrcTranslateDividerHeight, resources.getDimension(R.dimen.lrc_translate_divider_height)) + val defDuration = resources.getInteger(R.integer.lrc_animation_duration) + animationDuration = typedArray.getInt(R.styleable.LyricView_lrcAnimationDuration, defDuration).toLong() + animationDuration = + if (animationDuration < 0) defDuration.toLong() else animationDuration + + normalTextColor = typedArray.getColor( + R.styleable.LyricView_lrcNormalTextColor, + ContextCompat.getColor(context, R.color.lrc_normal_text_color) + ) + currentTextColor = typedArray.getColor( + R.styleable.LyricView_lrcCurrentTextColor, + ContextCompat.getColor(context, R.color.lrc_current_text_color) + ) + timelineTextColor = typedArray.getColor( + R.styleable.LyricView_lrcTimelineTextColor, + ContextCompat.getColor(context, R.color.lrc_timeline_text_color) + ) + defaultLabel = typedArray.getString(R.styleable.LyricView_lrcLabel) + defaultLabel = if (defaultLabel.isNullOrEmpty()) "暂无歌词" else defaultLabel + lrcPadding = typedArray.getDimension(R.styleable.LyricView_lrcPadding, 0f) + timelineColor = typedArray.getColor( + R.styleable.LyricView_lrcTimelineColor, + ContextCompat.getColor(context, R.color.lrc_timeline_color) + ) + val timelineHeight = typedArray.getDimension( + R.styleable.LyricView_lrcTimelineHeight, + resources.getDimension(R.dimen.lrc_timeline_height) + ) + playDrawable = typedArray.getDrawable(R.styleable.LyricView_lrcPlayDrawable) + playDrawable = if (playDrawable == null) ContextCompat.getDrawable( + context, + R.drawable.lrc_play + ) else playDrawable + timeTextColor = typedArray.getColor( + R.styleable.LyricView_lrcTimeTextColor, + ContextCompat.getColor(context, R.color.lrc_time_text_color) + ) + val timeTextSize = typedArray.getDimension( + R.styleable.LyricView_lrcTimeTextSize, + resources.getDimension(R.dimen.lrc_time_text_size) + ) + textGravity = typedArray.getInteger(R.styleable.LyricView_lrcTextGravity, GRAVITY_CENTER) + translateTextScaleValue = typedArray.getFloat(R.styleable.LyricView_lrcTranslateTextScaleValue, 1f) + horizontalOffset = typedArray.getDimension(R.styleable.LyricView_lrcHorizontalOffset, 0f) + horizontalOffsetPercent = typedArray.getDimension(R.styleable.LyricView_lrcHorizontalOffsetPercent, 0.5f) + itemOffsetPercent = typedArray.getDimension(R.styleable.LyricView_lrcItemOffsetPercent, 0.5f) + isDrawTranslation = typedArray.getBoolean(R.styleable.LyricView_lrcIsDrawTranslation, false) + typedArray.recycle() + drawableWidth = resources.getDimension(R.dimen.lrc_drawable_width).toInt() + timeTextWidth = resources.getDimension(R.dimen.lrc_time_width).toInt() + lyricPaint.isAntiAlias = true + lyricPaint.textSize = currentTextSize + lyricPaint.textAlign = Paint.Align.LEFT +// lyricPaint.setShadowLayer(0.1f, 0f, 1f, Color.DKGRAY) + secondLyricPaint.isAntiAlias = true + secondLyricPaint.textSize = currentTextSize + secondLyricPaint.textAlign = Paint.Align.LEFT +// secondLyricPaint.setShadowLayer(0.1f, 0f, 1f, Color.DKGRAY) + timePaint.isAntiAlias = true + timePaint.textSize = timeTextSize + timePaint.textAlign = Paint.Align.CENTER + timePaint.strokeWidth = timelineHeight + timePaint.strokeCap = Paint.Cap.ROUND + timeFontMetrics = timePaint.fontMetrics + gestureDetector = GestureDetector(context, mSimpleOnGestureListener) + gestureDetector!!.setIsLongpressEnabled(false) + scroller = Scroller(context) + } + + /** + * 歌词是否有效 + * @return true,如果歌词有效,否则false + */ + private fun hasLrc(): Boolean { + return lyricEntryList.isNotEmpty() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (changed) { + initPlayDrawable() + initEntryList() + if (hasLrc()) { + smoothScrollTo(currentLine) + } + } + readyHelper.readyState = STATE_INITIALIZED + } + + private val isShowTimeline = BoolValue().also(::registerValue) + private val isEnableBlurEffect = BoolValue().also(::registerValue) + private val progressKeeper = FloatListAnimateValue().also(::registerValue) + private val blurProgressKeeper = FloatListAnimateValue().also(::registerValue) + + private val heightKeeper = LinkedHashMap() + private val offsetKeeper = LinkedHashMap() + private val minOffsetKeeper = LinkedHashMap() + private val maxOffsetKeeper = LinkedHashMap() + + private var viewPortStartOffset: Float = 0f + private var isDrawTranslationValue = 0f + private var isDrawTranslation: Boolean = false + set(value) { + if (field == value) return + field = value + viewPortStartOffset = mViewPortOffset + isDrawTranslationAnimator.animateToFinalPosition(if (value) 1000f else 0f) + } + private val isDrawTranslationAnimator = springAnimationOf( + getter = { isDrawTranslationValue * 1000f }, + setter = { + isDrawTranslationValue = it / 1000f + + if (!isTouching && !isFling) { + viewPortSpringAnimator.cancel() + + val targetOffset = if (isDrawTranslation) getMaxOffset(focusLine) else getMinOffset(focusLine) + val animateValue = if (isDrawTranslation) isDrawTranslationValue else 1f - isDrawTranslationValue + + mViewPortOffset = lerp(viewPortStartOffset, targetOffset, animateValue) + } + invalidate() + }, + ).withSpringForceProperties { + dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY + stiffness = SpringForce.STIFFNESS_LOW + finalPosition = if (isDrawTranslation) 1000f else 0f + } + + override fun onPreDraw(canvas: Canvas): Boolean { + // 无歌词,只渲染一句无歌词的提示语句 + if (!hasLrc()) { + lyricPaint.color = currentTextColor + lyricPaint.textSize = normalTextSize + LyricEntry.createStaticLayout( + defaultLabel, + lyricPaint, + lrcWidth, + Layout.Alignment.ALIGN_CENTER + )?.let { + drawText( + canvas = canvas, + staticLayout = it, + calcHeightOnly = false, + yOffset = startOffset, + yClipPercentage = 1f + ) + } + return false + } + return super.onPreDraw(canvas) + } + + override fun onDoDraw(canvas: Canvas): Boolean { + val centerY = startOffset + val currentCenterLine = centerLine + + // 当显示时间线时,需要绘制时间线 + if (isShowTimeline.value || isShowTimeline.animateValue > 0f) { + val alpha = (isShowTimeline.animateValue * 255f).toInt() + + // 绘制播放按钮 + playDrawable?.let { + it.alpha = alpha + it.draw(canvas) + } + + // 绘制时间线 + timePaint.color = timelineColor + timePaint.alpha = alpha + canvas.drawLine( + timeTextWidth.toFloat(), centerY, + (width - timeTextWidth).toFloat(), centerY, timePaint + ) + + // 绘制当前时间 + val timeText = formatTime(lyricEntryList[currentCenterLine].time) + val timeX = width - timeTextWidth.toFloat() / 2 + val timeY = centerY - (timeFontMetrics!!.descent + timeFontMetrics!!.ascent) / 2 + timePaint.color = timeTextColor + timePaint.alpha = alpha + canvas.drawText(timeText, timeX, timeY, timePaint) + } + + canvas.translate(0f, mViewPortOffset) + + var yOffset = 0f + var yMinOffset = 0f + var yMaxOffset = 0f + var scaleValue: Float + var progress: Float + var radius: Int + var calcHeightOnly: Boolean + + for (i in lyricEntryList.indices) { + // 根据上一项所计算得到的offset值,判断当前元素是否在需要绘制的区间,如果不在,则只需要计算高度不进行绘制相关计算 + calcHeightOnly = getOffset(i - 1) !in (mViewPortOffset - height)..(mViewPortOffset + height) + progressKeeper.updateTargetValue(i, if (currentLine == i) animateProgress else 0f) + progress = progressKeeper.getValueByIndex(i) + scaleValue = 1f + radius = 0 + + if (!calcHeightOnly) { + when { + // 当前行动画未结束 + progress > 0f -> { + scaleValue = calcScaleValue(currentTextSize, normalTextSize, progress) + lyricPaint.color = lerpColor(normalTextColor, currentTextColor, progress.coerceIn(0f, 1f)) + } + + isShowTimeline.value && i == currentCenterLine -> { + lyricPaint.color = timelineTextColor + } + + else -> { + lyricPaint.color = normalTextColor + } + } + lyricPaint.textSize = normalTextSize + secondLyricPaint.textSize = lyricPaint.textSize * translateTextScaleValue + secondLyricPaint.color = lyricPaint.color + + if (isEnableBlurEffect.value || isEnableBlurEffect.animateValue > 0f) { + radius = when (i) { + currentCenterLine -> 0 + currentCenterLine + 1 -> 3 + currentCenterLine + 2, currentCenterLine - 1 -> 7 + currentCenterLine + 3, currentCenterLine - 2 -> 11 + currentCenterLine + 4, currentCenterLine - 3 -> 20 + else -> 20 + } + blurProgressKeeper.updateTargetValue(i, radius.toFloat()) + radius = blurProgressKeeper.getValueByIndex(i).toInt() + radius = (radius * isEnableBlurEffect.animateValue).toInt() + } + } + + val itemHeight = drawLyricEntry( + canvas = canvas, + entry = lyricEntryList[i], + calcHeightOnly = calcHeightOnly, + yOffset = yOffset, + scaleValue = scaleValue, + blurRadius = radius, + ) { minHeight, maxHeight -> + minOffsetKeeper[i] = yMinOffset + calcOffsetOfItem(minHeight, sentenceDividerHeight) + yMinOffset += minHeight + + maxOffsetKeeper[i] = yMaxOffset + calcOffsetOfItem(maxHeight, sentenceDividerHeight) + yMaxOffset += maxHeight + } + heightKeeper[i] = itemHeight + offsetKeeper[i] = yOffset + calcOffsetOfItem(itemHeight, sentenceDividerHeight) + yOffset += itemHeight + } + return super.onDoDraw(canvas) + } + + /** + * 画一组歌词语句 + * + * @param calcHeightOnly 是否只计算高度 + * @param yOffset 歌词中心 Y 坐标 + * @param scaleValue 缩放比例 + * @param blurRadius 模糊半径 + * + * @return 该组歌词的实际绘制高度 + */ + private fun drawLyricEntry( + canvas: Canvas, + entry: LyricEntry, + calcHeightOnly: Boolean, + yOffset: Float, + scaleValue: Float, + blurRadius: Int, + callback: (minHeight: Float, maxHeight: Float) -> Unit = { _, _ -> } + ): Float { + var tempHeight = 0f + var minTempHeight = 0f + var maxTempHeight = 0f + + entry.staticLayout?.let { + tempHeight += drawText( + canvas = canvas, + staticLayout = it, + calcHeightOnly = calcHeightOnly, + yOffset = yOffset, + yClipPercentage = 1f, + scale = scaleValue, + blurRadius = blurRadius + ) + minTempHeight = tempHeight + maxTempHeight = tempHeight + + entry.secondStaticLayout?.let { second -> + tempHeight += translateDividerHeight * isDrawTranslationValue + maxTempHeight += translateDividerHeight + + tempHeight += drawText( + canvas = canvas, + staticLayout = second, + calcHeightOnly = calcHeightOnly, + yOffset = yOffset + tempHeight, + yClipPercentage = isDrawTranslationValue, + alpha = isDrawTranslationValue, + scale = scaleValue, + blurRadius = blurRadius + ) { _, max -> + maxTempHeight += max + } + } + tempHeight += sentenceDividerHeight + minTempHeight += sentenceDividerHeight + maxTempHeight += sentenceDividerHeight + } + callback(minTempHeight, maxTempHeight) + return tempHeight + } + + /** + * 画一行歌词 + * + * @param calcHeightOnly 是否只计算高度 + * @param yOffset 歌词中心 Y 坐标 + * @param yClipPercentage 垂直裁剪比例 + * @param scale 缩放比例 + * @param alpha 透明度 + * @param blurRadius 模糊半径 实现类似AppleMusic的歌词语句的模糊效果 + * + * @return 实际绘制高度 + */ + private fun drawText( + canvas: Canvas, + staticLayout: StaticLayout, + calcHeightOnly: Boolean = false, + yOffset: Float, + @FloatRange(from = 0.0, to = 1.0) + yClipPercentage: Float = 1f, + scale: Float = 1f, + alpha: Float = 1f, + blurRadius: Int = 0, + callback: (minHeight: Float, maxHeight: Float) -> Unit = { _, _ -> } + ): Float { + if (staticLayout.lineCount == 0) { + callback(0f, 0f) + return 0f + } + if (calcHeightOnly) { + callback(0f, staticLayout.height.toFloat()) + return staticLayout.height * yClipPercentage + } + val lineHeight = staticLayout.height.toFloat() / staticLayout.lineCount.toFloat() + + var yTemp = 0f // y轴临时偏移量 + var pivotYTemp: Float // 缩放中心Y坐标 + var itemActualHeight: Float // 单行实际绘制高度 + var actualHeight = 0f // 实际绘制高度 + + staticLayout.paint.alpha = (alpha * 255f).toInt() + staticLayout.paint.maskFilter = blurMaskFilterExt.get(blurRadius) + + /** + * 由于对StaticLayout整个缩放会使其中间的行间距也被缩放(通过TextPaint的textSize缩放则不会), + * 导致其真实渲染高度大于StaticLayout的height属性的值,同时也没有其他的接口能实现相同的缩放效果(对TextSize缩放会显得卡卡的) + * + * 所以通过Canvas的clipRect,来分别对StaticLayout的每一行文字进行缩放和绘制(StaticLayout的各行高度是一致的) + */ + repeat(staticLayout.lineCount) { + itemActualHeight = lineHeight * yClipPercentage + pivotYTemp = yTemp + itemActualHeight - staticLayout.paint.descent() // TextPaint修改textSize所实现的缩放效果应该就是descent线上的缩放(感觉效果差不多) + + canvas.save() + canvas.translate(lrcPadding, yOffset) + canvas.clipRect(-lrcPadding, yTemp, staticLayout.width.toFloat() + lrcPadding, yTemp + itemActualHeight) + + // 根据文字的gravity设置缩放基点坐标 + when (textGravity) { + GRAVITY_LEFT -> canvas.scale(scale, scale, 0f, pivotYTemp) + GRAVITY_RIGHT -> { + canvas.scale(scale, scale, staticLayout.width.toFloat(), pivotYTemp) + } + + GRAVITY_CENTER -> { + canvas.scale(scale, scale, staticLayout.width / 2f, pivotYTemp) + } + } + staticLayout.draw(canvas) + canvas.restore() + yTemp += itemActualHeight + actualHeight += itemActualHeight + } + callback(0f, staticLayout.height.toFloat()) + return actualHeight + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + isTouching = false + if (hasLrc() && !isFling) { + // TODO 应该为Timeline独立设置一个Enable开关, 这样就可以不需要等待Timeline消失 + postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME) + } + } + return gestureDetector!!.onTouchEvent(event) + } + + /** + * 手势监听器 + */ + private val mSimpleOnGestureListener: SimpleOnGestureListener = + object : SimpleOnGestureListener() { + + override fun onDown(e: MotionEvent): Boolean { + // 有歌词并且设置了 mOnPlayClickListener + if (onPlayClickListener != null) { + scroller!!.forceFinished(true) + removeCallbacks(hideTimelineRunnable) + isTouching = true + invalidate() + return true + } + return super.onDown(e) + } + + override fun onScroll( + e1: MotionEvent, + e2: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (hasLrc()) { + // 如果没显示 Timeline 的时候,distanceY 一段距离后再显示时间线 + if (!isShowTimeline.value && abs(distanceY) >= 10) { + // 滚动显示时间线 + isShowTimeline.value = true + } + mViewPortOffset += -distanceY + mViewPortOffset.coerceIn(getOffset(lyricEntryList.size - 1), getOffset(0)) + invalidate() + return true + } + return super.onScroll(e1, e2, distanceX, distanceY) + } + + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (hasLrc()) { + scroller!!.fling( + 0, mViewPortOffset.toInt(), 0, + velocityY.toInt(), 0, 0, + getOffset(lyricEntryList.size - 1).toInt(), + getOffset(0).toInt() + ) + isFling = true + return true + } + return super.onFling(e1, e2, velocityX, velocityY) + } + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (!hasLrc() || !isShowTimeline.value || !e.insideOf(playDrawable?.bounds)) { + onSingerClickListener?.onClick() + return super.onSingleTapConfirmed(e) + } + + val centerLine = centerLine + val centerLineTime = lyricEntryList[centerLine].time + // onPlayClick 消费了才更新 UI + if (onPlayClickListener?.onPlayClick(centerLineTime) == true) { + isShowTimeline.value = false + removeCallbacks(hideTimelineRunnable) + smoothScrollTo(centerLine) + invalidate() + return true + } + return super.onSingleTapConfirmed(e) + } + } + + private val hideTimelineRunnable = Runnable { + if (hasLrc() && isShowTimeline.value) { + isShowTimeline.value = false + smoothScrollTo(currentLine) + } + } + + override fun computeScroll() { + if (scroller!!.computeScrollOffset()) { + mViewPortOffset = scroller!!.currY.toFloat() + invalidate() + } + if (isFling && scroller!!.isFinished) { + isFling = false + if (hasLrc() && !isTouching) { + adjustCenter() + postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME) + } + } + } + + override fun onDetachedFromWindow() { + removeCallbacks(hideTimelineRunnable) + super.onDetachedFromWindow() + } + + private fun onLrcLoaded(entryList: List?) { + if (!entryList.isNullOrEmpty()) { + lyricEntryList.addAll(entryList) + } + lyricEntryList.sort() + initEntryList() + invalidate() + } + + private fun initPlayDrawable() { + val l = (timeTextWidth - drawableWidth) / 2 + val t = startOffset.toInt() - drawableWidth / 2 + val r = l + drawableWidth + val b = t + drawableWidth + playDrawable!!.setBounds(l, t, r, b) + } + + private fun initEntryList() { + if (!hasLrc() || width == 0) { + return + } + /** + * StaticLayout 根据初始化时传入的 TextSize 计算换行的位置 + * 如果 [currentTextSize] 与 [normalTextSize] 相差较大, + * 则会导致歌词渲染时溢出边界,或行间距不足挤压在一起 + * + * 故计算出可能的最大 TextSize 以后,用其初始化,使 StaticLayout 拥有足够的高度 + */ + lyricPaint.textSize = max(currentTextSize, normalTextSize) + secondLyricPaint.textSize = lyricPaint.textSize * translateTextScaleValue + for (lrcEntry in lyricEntryList) { + lrcEntry.init( + lyricPaint, secondLyricPaint, + lrcWidth.toInt(), textGravity.toLayoutAlign() + ) + } + mCurrentOffset = startOffset + mViewPortOffset = startOffset + } + + private fun reset() { + // TODO 待完善reset的逻辑 + scroller!!.forceFinished(true) + isShowTimeline.value = false + isTouching = false + isFling = false + removeCallbacks(hideTimelineRunnable) + lyricEntryList.clear() + mCurrentOffset = 0f + mViewPortOffset = 0f + currentLine = 0 + invalidate() + } + + /** + * 将中心行微调至正中心 + */ + private fun adjustCenter() { + smoothScrollTo(currentLine) + } + + /** + * 平滑滚动过渡到某一行 + * + * @param line 行号 + */ + private fun smoothScrollTo(line: Int) { + val offset = getOffset(line) + animateStartOffset = mCurrentOffset + animateTargetOffset = offset + progressSpringAnimator.animateToFinalPosition(offset) + } + + /** + * 二分法查找当前时间应该显示的行数(最后一个 <= time 的行数) + */ + private fun findShowLine(time: Long): Int { + var left = 0 + var right = lyricEntryList.size + while (left <= right) { + val middle = (left + right) / 2 + val middleTime = lyricEntryList[middle].time + if (time < middleTime) { + right = middle - 1 + } else { + if (middle + 1 >= lyricEntryList.size || time < lyricEntryList[middle + 1].time) { + return middle + } + left = middle + 1 + } + } + return 0 + } + + /** + * 计算单个歌词元素的偏移量,用于控制歌词对其中线的位置 + * + * 计算出来的歌词高度包含了分割线的高度,所以需要减去分割线的高度 + * + * @param itemHeight 歌词元素的高度 + * @param dividerHeight 分割线的高度 + * + * @return 歌词元素的偏移量 + */ + protected open fun calcOffsetOfItem(itemHeight: Float, dividerHeight: Float): Float { + return (itemHeight - dividerHeight) * itemOffsetPercent + } + + /** + * 因为添加了 [translateDividerHeight] 用来间隔开歌词与翻译, + * 所以直接从 [LyricEntry] 获取高度不可行, + * 故使用该 [getLyricHeight] 方法来计算 [LyricEntry] 的高度 + */ + @Deprecated("不再单独计算歌词的高度,在绘制时计算并进行更新缓存,所见即所得") + open fun getLyricHeight(line: Int): Int { + var height = lyricEntryList[line].staticLayout?.height ?: return 0 + lyricEntryList[line].secondStaticLayout?.height?.let { + height += (it + translateDividerHeight).toInt() + } + return height + } + + /** + * 获取歌词距离视图顶部的距离 + */ + private fun getOffset(line: Int): Float { + return startOffset - (offsetKeeper[line] ?: 0f) + } + + private fun getMinOffset(line: Int): Float { + return startOffset - (minOffsetKeeper[line] ?: 0f) + } + + private fun getMaxOffset(line: Int): Float { + return startOffset - (maxOffsetKeeper[line] ?: 0f) + } + + /** + * 在主线程中运行 + */ + private fun runOnMain(r: Runnable) { + if (Looper.myLooper() == Looper.getMainLooper()) { + r.run() + } else { + post(r) + } + } + + /** + * 以下是公共部分 + * 用法见接口 [LyricViewXInterface] + */ + + override fun setSentenceDividerHeight(height: Float) { + sentenceDividerHeight = height + if (hasLrc()) { + smoothScrollTo(currentLine) + } + postInvalidate() + } + + override fun setTranslateDividerHeight(height: Float) { + translateDividerHeight = height + if (hasLrc()) { + smoothScrollTo(currentLine) + } + postInvalidate() + } + + override fun setHorizontalOffset(offset: Float) { + horizontalOffset = offset + initPlayDrawable() + postInvalidate() + } + + override fun setHorizontalOffsetPercent(percent: Float) { + horizontalOffsetPercent = percent + initPlayDrawable() + postInvalidate() + } + + override fun setTranslateTextScaleValue(scaleValue: Float) { + translateTextScaleValue = scaleValue + initEntryList() + if (hasLrc()) { + smoothScrollTo(currentLine) + } + } + + override fun setTextGravity(gravity: Int) { + textGravity = gravity + initEntryList() + if (hasLrc()) { + smoothScrollTo(currentLine) + } + } + + override fun setNormalColor(normalColor: Int) { + normalTextColor = normalColor + postInvalidate() + } + + override fun setNormalTextSize(size: Float) { + normalTextSize = size + initEntryList() + if (hasLrc()) { + smoothScrollTo(currentLine) + } + } + + override fun setCurrentTextSize(size: Float) { + currentTextSize = size + initEntryList() + if (hasLrc()) { + smoothScrollTo(currentLine) + } + } + + override fun setCurrentColor(currentColor: Int) { + currentTextColor = currentColor + postInvalidate() + } + + override fun setTimelineTextColor(timelineTextColor: Int) { + this.timelineTextColor = timelineTextColor + postInvalidate() + } + + override fun setTimelineColor(timelineColor: Int) { + this.timelineColor = timelineColor + postInvalidate() + } + + override fun setTimeTextColor(timeTextColor: Int) { + this.timeTextColor = timeTextColor + postInvalidate() + } + + override fun setLabel(label: String) { + runOnMain { + defaultLabel = label + this@LyricViewX.invalidate() + } + } + + override fun loadLyric(mainLyricText: String?, secondLyricText: String?) { + runOnMain { + reset() + val sb = StringBuilder("file://") + sb.append(mainLyricText) + if (secondLyricText != null) { + sb.append("#").append(secondLyricText) + } + val flag = sb.toString() + this@LyricViewX.flag = flag + thread { + val lrcEntries = LyricUtil.parseLrc(arrayOf(mainLyricText, secondLyricText)) + runOnMain { + onLrcLoaded(lrcEntries) + this@LyricViewX.flag = null + } + } + } + } + + override fun loadLyric(lyricEntries: List) { + runOnMain { + reset() + onLrcLoaded(lyricEntries) + } + } + + override fun updateTime(time: Long, force: Boolean) { + // 将方法的执行延后至 View 创建完成后执行 + readyHelper.whenReady { + if (!it) return@whenReady + if (hasLrc()) { + val line = findShowLine(time) + if (line != currentLine) { + runOnMain { + currentLine = line + smoothScrollTo(line) + } + } + } + } + } + + override fun setDraggable(draggable: Boolean, onPlayClickListener: OnPlayClickListener?) { + this.onPlayClickListener = if (draggable) { + requireNotNull(onPlayClickListener) { + "if draggable == true, onPlayClickListener must not be null" } + onPlayClickListener + } else { + null + } + } + + override fun setOnSingerClickListener(onSingerClickListener: OnSingleClickListener?) { + this.onSingerClickListener = onSingerClickListener + } + + override fun getLyricEntryList(): List { + return lyricEntryList.toList() + } + + override fun setLyricEntryList(newList: List) { + reset() + onLrcLoaded(newList) + this@LyricViewX.flag = null + } + + override fun getCurrentLineLyricEntry(): LyricEntry? { + if (currentLine <= lyricEntryList.lastIndex) { + return lyricEntryList[currentLine] + } + return null + } + + override fun setLyricTypeface(file: File) { + val typeface = file.takeIf { it.exists() } + ?.runCatching { Typeface.createFromFile(this) } + ?.getOrNull() ?: return + + setLyricTypeface(typeface) + } + + override fun setLyricTypeface(path: String) { + setLyricTypeface(File(path)) + } + + override fun setLyricTypeface(typeface: Typeface?) { + lyricPaint.typeface = typeface + secondLyricPaint.typeface = typeface + postInvalidate() + } + + override fun setDampingRatioForLyric(dampingRatio: Float) { + dampingRatioForLyric = dampingRatio + progressSpringAnimator.spring.dampingRatio = dampingRatio + } + + override fun setDampingRatioForViewPort(dampingRatio: Float) { + dampingRatioForViewPort = dampingRatio + viewPortSpringAnimator.spring.dampingRatio = dampingRatio + } + + override fun setStiffnessForLyric(stiffness: Float) { + stiffnessForLyric = stiffness + progressSpringAnimator.spring.stiffness = stiffness + } + + override fun setStiffnessForViewPort(stiffness: Float) { + stiffnessForViewPort = stiffness + viewPortSpringAnimator.spring.stiffness = stiffness + } + + override fun setPlayDrawable(drawable: Drawable) { + playDrawable = drawable + } + + override fun setIsDrawTranslation(isDrawTranslation: Boolean) { + this.isDrawTranslation = isDrawTranslation + postInvalidate() + } + + override fun setIsEnableBlurEffect(isEnableBlurEffect: Boolean) { + this.isEnableBlurEffect.value = isEnableBlurEffect + postInvalidate() + } + + override fun setItemOffsetPercent(itemOffsetPercent: Float) { + this.itemOffsetPercent = itemOffsetPercent + postInvalidate() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/dirror/lyricviewx/ReadyHelper.kt b/app/src/main/java/com/dirror/lyricviewx/ReadyHelper.kt new file mode 100644 index 0000000..9b81331 --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/ReadyHelper.kt @@ -0,0 +1,51 @@ +package com.dirror.lyricviewx + +import androidx.annotation.IntDef + +const val STATE_CREATED = 1 +const val STATE_INITIALIZING = 2 +const val STATE_INITIALIZED = 3 +const val STATE_ERROR = 4 + +@IntDef( + STATE_CREATED, + STATE_INITIALIZING, + STATE_INITIALIZED, + STATE_ERROR +) +@Retention(AnnotationRetention.SOURCE) +annotation class ReadyState + +/** + * 简单的状态机,根据 [readyState] 的状态决定当前任务的执行或延后与否 + */ +open class ReadyHelper { + private var readyCallback: (Boolean) -> Unit = {} + + @ReadyState + var readyState: Int = STATE_CREATED + set(value) { + if (field == value) return + when (value) { + STATE_INITIALIZED, + STATE_ERROR -> synchronized(readyCallback) { + field = value + readyCallback.invoke(value != STATE_ERROR) + } + else -> field = value + } + } + + fun whenReady(performAction: (Boolean) -> Unit): Boolean { + return when (readyState) { + STATE_CREATED, STATE_INITIALIZING -> { + readyCallback = performAction + false + } + else -> { + performAction(readyState != STATE_ERROR) + true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/dirror/lyricviewx/extension/BlurMaskFilterExt.kt b/app/src/main/java/com/dirror/lyricviewx/extension/BlurMaskFilterExt.kt new file mode 100644 index 0000000..f5a8558 --- /dev/null +++ b/app/src/main/java/com/dirror/lyricviewx/extension/BlurMaskFilterExt.kt @@ -0,0 +1,15 @@ +package com.dirror.lyricviewx.extension + +import android.graphics.BlurMaskFilter +import android.util.SparseArray + +class BlurMaskFilterExt { + private val maskFilterCache = SparseArray() + + fun get(radius: Int): BlurMaskFilter? { + if (radius == 0 || radius > 25) return null + + return maskFilterCache[radius] ?: BlurMaskFilter(radius.toFloat(), BlurMaskFilter.Blur.NORMAL) + .also { maskFilterCache.put(radius, it) } + } +} \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/icon.png b/app/src/main/res/mipmap-hdpi/icon.png new file mode 100644 index 0000000..22103ca Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon.png differ diff --git a/lyricviewx/.gitignore b/lyricviewx/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/lyricviewx/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lyricviewx/build.gradle b/lyricviewx/build.gradle new file mode 100644 index 0000000..679ac30 --- /dev/null +++ b/lyricviewx/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'com.android.library' +} +android { + compileSdkVersion 32 + namespace 'com.jaeger.library' + defaultConfig { + minSdk 23 + //noinspection ExpiredTargetSdkVersion,OldTargetApi + targetSdk 31 + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + + +dependencies { +// implementation "org.jetbrains.kotlin:kotlin-stdlib:1.8.0" + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' +} \ No newline at end of file