diff --git a/app/release/Cloud_music-release-v1.9.7.apk b/app/release/Cloud_music-release-v1.9.7.apk
new file mode 100644
index 0000000..8762f18
Binary files /dev/null and b/app/release/Cloud_music-release-v1.9.7.apk differ
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 0000000..f836a69
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.muqingbfq",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 1,
+ "versionName": "1.9.7",
+ "outputFile": "Cloud_music-release-v1.9.7.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/lrcview/.gitignore b/lrcview/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/lrcview/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/lrcview/build.gradle b/lrcview/build.gradle
new file mode 100644
index 0000000..d8417cb
--- /dev/null
+++ b/lrcview/build.gradle
@@ -0,0 +1,22 @@
+apply plugin: 'com.android.library'
+apply plugin: 'maven-publish'
+
+android {
+ namespace 'me.wcy.lrcview'
+ compileSdk 34
+ defaultConfig {
+ minSdkVersion 23
+ //noinspection ExpiredTargetSdkVersion
+ targetSdk 31
+ versionCode 1
+ versionName "1.0"
+ }
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+}
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.9.0'
+}
\ No newline at end of file
diff --git a/lrcview/src/main/AndroidManifest.xml b/lrcview/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..05ecef7
--- /dev/null
+++ b/lrcview/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/lrcview/src/main/java/me/wcy/lrcview/LrcEntry.java b/lrcview/src/main/java/me/wcy/lrcview/LrcEntry.java
new file mode 100644
index 0000000..0a5a764
--- /dev/null
+++ b/lrcview/src/main/java/me/wcy/lrcview/LrcEntry.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2017 wangchenyan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package me.wcy.lrcview;
+
+import android.text.Layout;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import android.text.TextUtils;
+
+/**
+ * 一行歌词实体
+ */
+public class LrcEntry implements Comparable {
+ public long time;
+ public String text, secondText;
+ private StaticLayout staticLayout;
+ /**
+ * 歌词距离视图顶部的距离
+ */
+ private float offset = Float.MIN_VALUE;
+ public static final int GRAVITY_CENTER = 0;
+ public static final int GRAVITY_LEFT = 1;
+ public static final int GRAVITY_RIGHT = 2;
+
+ LrcEntry(long time, String text) {
+ this.time = time;
+ this.text = text;
+ }
+
+ LrcEntry(long time, String text, String secondText) {
+ this.time = time;
+ this.text = text;
+ this.secondText = secondText;
+ }
+
+ void init(TextPaint paint, int width, int gravity) {
+ Layout.Alignment align;
+ switch (gravity) {
+ case GRAVITY_LEFT:
+ align = Layout.Alignment.ALIGN_NORMAL;
+ break;
+
+ default:
+ case GRAVITY_CENTER:
+ align = Layout.Alignment.ALIGN_CENTER;
+ break;
+
+ case GRAVITY_RIGHT:
+ align = Layout.Alignment.ALIGN_OPPOSITE;
+ break;
+ }
+ staticLayout = new StaticLayout(
+ getShowText(), paint, width, align, 1f, 0f, false);
+
+ offset = Float.MIN_VALUE;
+ }
+
+ long getTime() {
+ return time;
+ }
+
+ StaticLayout getStaticLayout() {
+ return staticLayout;
+ }
+
+ int getHeight() {
+ if (staticLayout == null) {
+ return 0;
+ }
+ return staticLayout.getHeight();
+ }
+
+ public float getOffset() {
+ return offset;
+ }
+
+ public void setOffset(float offset) {
+ this.offset = offset;
+ }
+
+ String getText() {
+ return text;
+ }
+
+
+ void setSecondText(String secondText) {
+ this.secondText = secondText;
+ }
+
+ public String getShowText() {
+ if (!TextUtils.isEmpty(secondText)) {
+ return text + "\n" + secondText;
+ } else {
+ return text;
+ }
+ }
+
+ @Override
+ public int compareTo(LrcEntry entry) {
+ if (entry == null) {
+ return -1;
+ }
+ return (int) (time - entry.getTime());
+ }
+}
diff --git a/lrcview/src/main/java/me/wcy/lrcview/LrcUtils.java b/lrcview/src/main/java/me/wcy/lrcview/LrcUtils.java
new file mode 100644
index 0000000..bd088b5
--- /dev/null
+++ b/lrcview/src/main/java/me/wcy/lrcview/LrcUtils.java
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2017 wangchenyan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package me.wcy.lrcview;
+
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 工具类
+ */
+class LrcUtils {
+ private static final Pattern PATTERN_LINE = Pattern.compile("((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)");
+ private static final Pattern PATTERN_TIME = Pattern.compile("\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]");
+
+ /**
+ * 从文件解析双语歌词
+ */
+ static List parseLrc(File[] lrcFiles) {
+ if (lrcFiles == null || lrcFiles.length != 2 || lrcFiles[0] == null) {
+ return null;
+ }
+
+ File mainLrcFile = lrcFiles[0];
+ File secondLrcFile = lrcFiles[1];
+ List mainEntryList = parseLrc(mainLrcFile);
+ List secondEntryList = parseLrc(secondLrcFile);
+
+ if (mainEntryList != null && secondEntryList != null) {
+ for (LrcEntry mainEntry : mainEntryList) {
+ for (LrcEntry secondEntry : secondEntryList) {
+ if (mainEntry.getTime() == secondEntry.getTime()) {
+ mainEntry.setSecondText(secondEntry.getText());
+ }
+ }
+ }
+ }
+ return mainEntryList;
+ }
+
+ /**
+ * 从文件解析歌词
+ */
+ private static List parseLrc(File lrcFile) {
+ if (lrcFile == null || !lrcFile.exists()) {
+ return null;
+ }
+
+ List entryList = new ArrayList<>();
+ try {
+ @SuppressLint({"NewApi", "LocalSuppress"})
+ BufferedReader br = new BufferedReader(new InputStreamReader(
+ Files.newInputStream(lrcFile.toPath()), StandardCharsets.UTF_8));
+ String line;
+ while ((line = br.readLine()) != null) {
+ List list = parseLine(line);
+ if (list != null && !list.isEmpty()) {
+ entryList.addAll(list);
+ }
+ }
+ br.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ Collections.sort(entryList);
+ return entryList;
+ }
+
+ /**
+ * 从文本解析双语歌词
+ */
+ static List parseLrc(String[] lrcTexts) {
+ if (lrcTexts == null || lrcTexts.length != 2 || TextUtils.isEmpty(lrcTexts[0])) {
+ return null;
+ }
+
+ String mainLrcText = lrcTexts[0];
+ String secondLrcText = lrcTexts[1];
+ List mainEntryList = parseLrc(mainLrcText);
+ List secondEntryList = parseLrc(secondLrcText);
+
+ if (mainEntryList != null && secondEntryList != null) {
+ for (LrcEntry mainEntry : mainEntryList) {
+ for (LrcEntry secondEntry : secondEntryList) {
+ if (mainEntry.getTime() == secondEntry.getTime()) {
+ mainEntry.setSecondText(secondEntry.getText());
+ }
+ }
+ }
+ }
+ return mainEntryList;
+ }
+
+ /**
+ * 从文本解析歌词
+ */
+ private static List parseLrc(String lrcText) {
+ if (TextUtils.isEmpty(lrcText)) {
+ return null;
+ }
+
+ if (lrcText.startsWith("\uFEFF")) {
+ lrcText = lrcText.replace("\uFEFF", "");
+ }
+
+ List entryList = new ArrayList<>();
+ String[] array = lrcText.split("\\n");
+ for (String line : array) {
+ List list = parseLine(line);
+ if (list != null && !list.isEmpty()) {
+ entryList.addAll(list);
+ }
+ }
+
+ Collections.sort(entryList);
+ return entryList;
+ }
+
+ /**
+ * 获取网络文本,需要在工作线程中执行
+ */
+ static String getContentFromNetwork(String url, String charset) {
+ String lrcText = null;
+ try {
+ URL _url = new URL(url);
+ HttpURLConnection conn = (HttpURLConnection) _url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(10000);
+ if (conn.getResponseCode() == 200) {
+ InputStream is = conn.getInputStream();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int len;
+ while ((len = is.read(buffer)) != -1) {
+ bos.write(buffer, 0, len);
+ }
+ is.close();
+ bos.close();
+ lrcText = bos.toString(charset);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return lrcText;
+ }
+
+ /**
+ * 解析一行歌词
+ */
+ private static List parseLine(String line) {
+ if (TextUtils.isEmpty(line)) {
+ return null;
+ }
+
+ line = line.trim();
+ // [00:17.65]让我掉下眼泪的
+ Matcher lineMatcher = PATTERN_LINE.matcher(line);
+ if (!lineMatcher.matches()) {
+ return null;
+ }
+
+ String times = lineMatcher.group(1);
+ String text = lineMatcher.group(3);
+ List entryList = new ArrayList<>();
+
+ // [00:17.65]
+ Matcher timeMatcher = PATTERN_TIME.matcher(times);
+ while (timeMatcher.find()) {
+ long min = Long.parseLong(timeMatcher.group(1));
+ long sec = Long.parseLong(timeMatcher.group(2));
+ String milString = timeMatcher.group(3);
+ long mil = Long.parseLong(milString);
+ // 如果毫秒是两位数,需要乘以10
+ if (milString.length() == 2) {
+ mil = mil * 10;
+ }
+ long time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil;
+ entryList.add(new LrcEntry(time, text));
+ }
+ return entryList;
+ }
+
+ /**
+ * 转为[分:秒]
+ */
+ static String formatTime(long milli) {
+ int m = (int) (milli / DateUtils.MINUTE_IN_MILLIS);
+ int s = (int) ((milli / DateUtils.SECOND_IN_MILLIS) % 60);
+ String mm = String.format(Locale.getDefault(), "%02d", m);
+ String ss = String.format(Locale.getDefault(), "%02d", s);
+ return mm + ":" + ss;
+ }
+
+ /**
+ * 强制开启动画
+ * Android 10 以后无法使用
+ */
+ static void resetDurationScale() {
+ try {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+ @SuppressLint("DiscouragedPrivateApi")
+ Field mField = ValueAnimator.class.getDeclaredField("sDurationScale");
+ mField.setAccessible(true);
+ mField.setFloat(null, 1);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/lrcview/src/main/java/me/wcy/lrcview/LrcView.java b/lrcview/src/main/java/me/wcy/lrcview/LrcView.java
new file mode 100644
index 0000000..4afd6f2
--- /dev/null
+++ b/lrcview/src/main/java/me/wcy/lrcview/LrcView.java
@@ -0,0 +1,811 @@
+/*
+ * Copyright (C) 2017 wangchenyan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package me.wcy.lrcview;
+
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.drawable.Drawable;
+import android.os.Looper;
+import android.text.Layout;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.animation.LinearInterpolator;
+import android.widget.Scroller;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+/**
+ * 歌词
+ * Created by wcy on 2015/11/9.
+ *
+ * @noinspection unused
+ */
+@SuppressLint("StaticFieldLeak")
+public class LrcView extends View {
+ private static final String TAG = "LrcView";
+ private static final long ADJUST_DURATION = 100;
+ private static final long TIMELINE_KEEP_TIME = 4 * DateUtils.SECOND_IN_MILLIS;
+ public static List mLrcEntryList = new ArrayList<>();
+ private TextPaint mLrcPaint = new TextPaint(), mTimePaint = new TextPaint();
+ private Paint.FontMetrics mTimeFontMetrics;
+ private Drawable mPlayDrawable;
+ private float mDividerHeight;
+ private long mAnimationDuration;
+ private int mNormalTextColor;
+ private float mNormalTextSize;
+ private int mCurrentTextColor;
+ private float mCurrentTextSize;
+ private int mTimelineTextColor;
+ private int mTimelineColor;
+ private int mTimeTextColor;
+ private int mDrawableWidth;
+ private int mTimeTextWidth;
+ private String mDefaultLabel;
+ private float mLrcPadding;
+ private OnPlayClickListener mOnPlayClickListener;
+ private OnTapListener mOnTapListener;
+ private ValueAnimator mAnimator;
+ private GestureDetector mGestureDetector;
+ private Scroller mScroller;
+ private float mOffset;
+ private int mCurrentLine;
+ private Object mFlag;
+ private boolean isShowTimeline;
+ private boolean isTouching;
+ private boolean isFling;
+ /**
+ * 歌词显示位置,靠左/居中/靠右
+ */
+ private int mTextGravity;
+
+ /**
+ * 播放按钮点击监听器,点击后应该跳转到指定播放位置
+ */
+ public interface OnPlayClickListener {
+ /**
+ * 播放按钮被点击,应该跳转到指定播放位置
+ *
+ * @param view 歌词控件
+ * @param time 选中播放进度
+ * @return 是否成功消费该事件,如果成功消费,则会更新UI
+ */
+ boolean onPlayClick(LrcView view, long time);
+ }
+
+ /**
+ * 歌词控件点击监听器
+ */
+ public interface OnTapListener {
+ /**
+ * 歌词控件被点击
+ *
+ * @param view 歌词控件
+ * @param x 点击坐标x,相对于控件
+ * @param y 点击坐标y,相对于控件
+ */
+ void onTap(LrcView view, float x, float y);
+ }
+
+ public LrcView(Context context) {
+ this(context, null);
+ }
+
+ public LrcView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public LrcView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(attrs);
+ }
+
+ private void init(AttributeSet attrs) {
+ TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.LrcView);
+ mCurrentTextSize = ta.getDimension(R.styleable.LrcView_lrcTextSize, getResources().getDimension(R.dimen.lrc_text_size));
+ mNormalTextSize = ta.getDimension(R.styleable.LrcView_lrcNormalTextSize, getResources().getDimension(R.dimen.lrc_text_size));
+ if (mNormalTextSize == 0) {
+ mNormalTextSize = mCurrentTextSize;
+ }
+
+ mDividerHeight = ta.getDimension(R.styleable.LrcView_lrcDividerHeight, getResources().getDimension(R.dimen.lrc_divider_height));
+ int defDuration = getResources().getInteger(R.integer.lrc_animation_duration);
+ mAnimationDuration = ta.getInt(R.styleable.LrcView_lrcAnimationDuration, defDuration);
+ mAnimationDuration = (mAnimationDuration < 0) ? defDuration : mAnimationDuration;
+ mNormalTextColor = ta.getColor(R.styleable.LrcView_lrcNormalTextColor,
+ ContextCompat.getColor(getContext(), R.color.lrc_normal_text_color));
+ mCurrentTextColor = ta.getColor(R.styleable.LrcView_lrcCurrentTextColor,
+ ContextCompat.getColor(getContext(), R.color.lrc_current_text_color));
+ mTimelineTextColor = ta.getColor(R.styleable.LrcView_lrcTimelineTextColor,
+ ContextCompat.getColor(getContext(), R.color.lrc_timeline_text_color));
+ mDefaultLabel = ta.getString(R.styleable.LrcView_lrcLabel);
+ mDefaultLabel = TextUtils.isEmpty(mDefaultLabel) ? getContext().getString(R.string.lrc_label) : mDefaultLabel;
+ mLrcPadding = ta.getDimension(R.styleable.LrcView_lrcPadding, 0);
+ mTimelineColor = ta.getColor(R.styleable.LrcView_lrcTimelineColor,
+ ContextCompat.getColor(getContext(), R.color.lrc_timeline_color));
+ float timelineHeight = ta.getDimension(R.styleable.LrcView_lrcTimelineHeight, getResources().getDimension(R.dimen.lrc_timeline_height));
+ mPlayDrawable = ta.getDrawable(R.styleable.LrcView_lrcPlayDrawable);
+ mPlayDrawable = (mPlayDrawable == null) ?
+ ContextCompat.getDrawable(getContext(), R.drawable.lrc_play) : mPlayDrawable;
+ mTimeTextColor = ta.getColor(R.styleable.LrcView_lrcTimeTextColor,
+ ContextCompat.getColor(getContext(), R.color.lrc_time_text_color));
+ float timeTextSize = ta.getDimension(R.styleable.LrcView_lrcTimeTextSize, getResources().getDimension(R.dimen.lrc_time_text_size));
+ mTextGravity = ta.getInteger(R.styleable.LrcView_lrcTextGravity, LrcEntry.GRAVITY_CENTER);
+
+ ta.recycle();
+
+ mDrawableWidth = (int) getResources().getDimension(R.dimen.lrc_drawable_width);
+ mTimeTextWidth = (int) getResources().getDimension(R.dimen.lrc_time_width);
+
+ mLrcPaint.setAntiAlias(true);
+ mLrcPaint.setTextSize(mCurrentTextSize);
+ mLrcPaint.setTextAlign(Paint.Align.LEFT);
+ mTimePaint.setAntiAlias(true);
+ mTimePaint.setTextSize(timeTextSize);
+ mTimePaint.setTextAlign(Paint.Align.CENTER);
+ //noinspection SuspiciousNameCombination
+ mTimePaint.setStrokeWidth(timelineHeight);
+ mTimePaint.setStrokeCap(Paint.Cap.ROUND);
+ mTimeFontMetrics = mTimePaint.getFontMetrics();
+
+ mGestureDetector = new GestureDetector(getContext(), mSimpleOnGestureListener);
+ mGestureDetector.setIsLongpressEnabled(false);
+ mScroller = new Scroller(getContext());
+ }
+
+ /**
+ * 设置非当前行歌词字体颜色
+ */
+ public void setNormalColor(int normalColor) {
+ mNormalTextColor = normalColor;
+ postInvalidate();
+ }
+
+ /**
+ * 普通歌词文本字体大小
+ */
+ public void setNormalTextSize(float size) {
+ mNormalTextSize = size;
+ }
+
+ /**
+ * 当前歌词文本字体大小
+ */
+ public void setCurrentTextSize(float size) {
+ mCurrentTextSize = size;
+ }
+
+ /**
+ * 设置当前行歌词的字体颜色
+ */
+ public void setCurrentColor(int currentColor) {
+ mCurrentTextColor = currentColor;
+ postInvalidate();
+ }
+
+ /**
+ * 设置拖动歌词时选中歌词的字体颜色
+ */
+ public void setTimelineTextColor(int timelineTextColor) {
+ mTimelineTextColor = timelineTextColor;
+ postInvalidate();
+ }
+
+ /**
+ * 设置拖动歌词时时间线的颜色
+ */
+ public void setTimelineColor(int timelineColor) {
+ mTimelineColor = timelineColor;
+ postInvalidate();
+ }
+
+ /**
+ * 设置拖动歌词时右侧时间字体颜色
+ */
+ public void setTimeTextColor(int timeTextColor) {
+ mTimeTextColor = timeTextColor;
+ postInvalidate();
+ }
+
+ /**
+ * 设置歌词是否允许拖动
+ *
+ * @param draggable 是否允许拖动
+ * @param onPlayClickListener 设置歌词拖动后播放按钮点击监听器,如果允许拖动,则不能为 null
+ */
+ public void setDraggable(boolean draggable, OnPlayClickListener onPlayClickListener) {
+ if (draggable) {
+ if (onPlayClickListener == null) {
+ throw new IllegalArgumentException("if draggable == true, onPlayClickListener must not be null");
+ }
+ mOnPlayClickListener = onPlayClickListener;
+ } else {
+ mOnPlayClickListener = null;
+ }
+ }
+
+ /**
+ * 设置播放按钮点击监听器
+ *
+ * @param onPlayClickListener 如果为非 null ,则激活歌词拖动功能,否则将将禁用歌词拖动功能
+ * @deprecated use {@link #setDraggable(boolean, OnPlayClickListener)} instead
+ */
+ @Deprecated
+ public void setOnPlayClickListener(OnPlayClickListener onPlayClickListener) {
+ mOnPlayClickListener = onPlayClickListener;
+ }
+
+ /**
+ * 设置歌词控件点击监听器
+ *
+ * @param onTapListener 歌词控件点击监听器
+ */
+ public void setOnTapListener(OnTapListener onTapListener) {
+ mOnTapListener = onTapListener;
+ }
+
+ /**
+ * 设置歌词为空时屏幕中央显示的文字,如“暂无歌词”
+ */
+ public void setLabel(String label) {
+ runOnUi(() -> {
+ mDefaultLabel = label;
+ invalidate();
+ });
+ }
+
+ /**
+ * 加载歌词文件
+ *
+ * @param lrcFile 歌词文件
+ */
+ public void loadLrc(File lrcFile) {
+ loadLrc(lrcFile, null);
+ }
+
+ /**
+ * 加载双语歌词文件,两种语言的歌词时间戳需要一致
+ *
+ * @param mainLrcFile 第一种语言歌词文件
+ * @param secondLrcFile 第二种语言歌词文件
+ */
+ public void loadLrc(File mainLrcFile, File secondLrcFile) {
+ runOnUi(() -> {
+ reset();
+
+ StringBuilder sb = new StringBuilder("file://");
+ sb.append(mainLrcFile.getPath());
+ if (secondLrcFile != null) {
+ sb.append("#").append(secondLrcFile.getPath());
+ }
+ String flag = sb.toString();
+ setFlag(flag);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ Callable> callable = () -> LrcUtils.parseLrc(new File[]{mainLrcFile, secondLrcFile});
+ Future> future = executor.submit(callable);
+ try {
+ List lrcEntries = future.get();
+ if (getFlag() == flag) {
+ onLrcLoaded(lrcEntries);
+ setFlag(null);
+ }
+ } catch (InterruptedException e) {
+ // 处理中断异常
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ // 处理执行异常
+ e.printStackTrace();
+ }
+// 关闭线程池
+ executor.shutdown();
+ });
+ }
+
+ /**
+ * 加载歌词文本
+ *
+ * @param lrcText 歌词文本
+ */
+ public void loadLrc(String lrcText) {
+ loadLrc(lrcText, null);
+ }
+
+ /**
+ * 加载双语歌词文本,两种语言的歌词时间戳需要一致
+ *
+ * @param mainLrcText 第一种语言歌词文本
+ * @param secondLrcText 第二种语言歌词文本
+ */
+ public void loadLrc(String mainLrcText, String secondLrcText) {
+ runOnUi(() -> {
+ reset();
+
+ StringBuilder sb = new StringBuilder("file://");
+ sb.append(mainLrcText);
+ if (secondLrcText != null) {
+ sb.append("#").append(secondLrcText);
+ }
+ String flag = sb.toString();
+ setFlag(flag);
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ Callable> callable = () -> LrcUtils.parseLrc(
+ new String[]{mainLrcText, secondLrcText});
+ Future> future = executor.submit(callable);
+ try {
+ List lrcEntries = future.get();
+ if (getFlag() == flag) {
+ onLrcLoaded(lrcEntries);
+ setFlag(null);
+ }
+ } catch (InterruptedException e) {
+ // 处理中断异常
+ e.printStackTrace();
+ } catch (ExecutionException e) {
+ // 处理执行异常
+ e.printStackTrace();
+ }
+// 关闭线程池
+ executor.shutdown();
+ });
+ }
+
+ /**
+ * 歌词是否有效
+ *
+ * @return true,如果歌词有效,否则false
+ */
+ public boolean hasLrc() {
+ return !mLrcEntryList.isEmpty();
+ }
+
+ /**
+ * 刷新歌词
+ * time 当前播放时间
+ */
+
+ public int index = 0;
+ public void updateTime(long time) {
+ if (mOnPlayClickListener == null) {
+ runOnUi(() -> {
+ if (!hasLrc()) {
+ return;
+ }
+ index = findShowLine(time);
+ invalidate();
+ });
+ return;
+ }
+ runOnUi(() -> {
+ if (!hasLrc()) {
+ return;
+ }
+ int line = findShowLine(time);
+ if (line != mCurrentLine) {
+ mCurrentLine = line;
+ if (!isShowTimeline) {
+ smoothScrollTo(line);
+ } else {
+ invalidate();
+ }
+ }
+ });
+ }
+
+ /**
+ * 将歌词滚动到指定时间
+ *
+ * @param time 指定的时间
+ * @deprecated 请使用 {@link #updateTime(long)} 代替
+ */
+ @Deprecated
+ public void onDrag(long time) {
+ updateTime(time);
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ if (changed) {
+ initPlayDrawable();
+ initEntryList();
+ if (hasLrc()) {
+ smoothScrollTo(mCurrentLine, 0L);
+ }
+ }
+ }
+
+ @Override
+ protected void onDraw(@NonNull Canvas canvas) {
+ super.onDraw(canvas);
+ int centerY = getHeight() / 2;
+ // 无歌词文件
+ if (!hasLrc()) {
+ mLrcPaint.setColor(mCurrentTextColor);
+ @SuppressLint("DrawAllocation")
+ StaticLayout staticLayout = new StaticLayout(mDefaultLabel, mLrcPaint,
+ (int) getLrcWidth(), Layout.Alignment.ALIGN_CENTER, 1f, 0f, false);
+ drawText(canvas, staticLayout, centerY);
+ return;
+ }
+
+ if (mOnPlayClickListener == null) {
+ mLrcPaint.setColor(mCurrentTextColor);
+ @SuppressLint("DrawAllocation")
+ StaticLayout staticLayout = new StaticLayout(mLrcEntryList.get(index).getShowText()
+ , mLrcPaint,
+ (int) getLrcWidth(), Layout.Alignment.ALIGN_CENTER, 1f,
+ 0f, false);
+ drawText(canvas, staticLayout, centerY);
+ return;
+ }
+
+ int centerLine = getCenterLine();
+
+ if (isShowTimeline) {
+ mPlayDrawable.draw(canvas);
+
+ mTimePaint.setColor(mTimelineColor);
+ canvas.drawLine(mTimeTextWidth, centerY, getWidth() - mTimeTextWidth, centerY, mTimePaint);
+
+ mTimePaint.setColor(mTimeTextColor);
+ String timeText = LrcUtils.formatTime(mLrcEntryList.get(centerLine).getTime());
+ float timeX = getWidth() - (float) mTimeTextWidth / 2;
+ float timeY = centerY - (mTimeFontMetrics.descent + mTimeFontMetrics.ascent) / 2;
+ canvas.drawText(timeText, timeX, timeY, mTimePaint);
+ }
+
+ canvas.translate(0, mOffset);
+
+ float y = 0;
+ for (int i = 0; i < mLrcEntryList.size(); i++) {
+
+ if (i > 0) {
+ y += ((mLrcEntryList.get(i - 1).getHeight() + mLrcEntryList.get(i).getHeight()) >> 1)
+ + mDividerHeight;
+ }
+ if (i == mCurrentLine) {
+ mLrcPaint.setTextSize(mCurrentTextSize);
+ mLrcPaint.setColor(mCurrentTextColor);
+ } else if (isShowTimeline && i == centerLine) {
+ mLrcPaint.setColor(mTimelineTextColor);
+ } else {
+ mLrcPaint.setTextSize(mNormalTextSize);
+ mLrcPaint.setColor(mNormalTextColor);
+ }
+ drawText(canvas, mLrcEntryList.get(i).getStaticLayout(), y);
+ }
+ }
+
+ /**
+ * 画一行歌词
+ *
+ * @param y 歌词中心 Y 坐标
+ */
+ private void drawText(Canvas canvas, StaticLayout staticLayout, float y) {
+ canvas.save();
+ canvas.translate(mLrcPadding, y - (staticLayout.getHeight() >> 1));
+ staticLayout.draw(canvas);
+ canvas.restore();
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) {
+ isTouching = false;
+ // 手指离开屏幕,启动延时任务,恢复歌词位置
+ if (hasLrc() && isShowTimeline) {
+ adjustCenter();
+ postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME);
+ }
+ }
+ return mGestureDetector.onTouchEvent(event);
+ }
+
+ /**
+ * 手势监听器
+ */
+ private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener =
+ new GestureDetector.SimpleOnGestureListener() {
+ // 本次点击仅仅为了停止歌词滚动,则不响应点击事件
+ private boolean isTouchForStopFling = false;
+
+ @Override
+ public boolean onDown(@NonNull MotionEvent e) {
+ if (!hasLrc()) {
+ return mOnTapListener != null;
+ }
+ isTouching = true;
+ removeCallbacks(hideTimelineRunnable);
+ if (isFling) {
+ isTouchForStopFling = true;
+ mScroller.forceFinished(true);
+ } else {
+ isTouchForStopFling = false;
+ }
+ return mOnPlayClickListener != null || mOnTapListener != null;
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
+ if (!hasLrc() || mOnPlayClickListener == null) {
+ return super.onScroll(e1, e2, distanceX, distanceY);
+ }
+ if (!isShowTimeline) {
+ isShowTimeline = true;
+ } else {
+ mOffset -= distanceY;
+ mOffset = Math.min(mOffset, getOffset(0));
+ mOffset = Math.max(mOffset, getOffset(mLrcEntryList.size() - 1));
+ }
+ invalidate();
+ return true;
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) {
+ if (!hasLrc() || mOnPlayClickListener == null) {
+ return super.onFling(e1, e2, velocityX, velocityY);
+ }
+ if (isShowTimeline) {
+ isFling = true;
+ removeCallbacks(hideTimelineRunnable);
+ mScroller.fling(0, (int) mOffset, 0, (int) velocityY, 0, 0, (int) getOffset(mLrcEntryList.size() - 1), (int) getOffset(0));
+ return true;
+ }
+ return super.onFling(e1, e2, velocityX, velocityY);
+ }
+
+ @Override
+ public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
+ if (hasLrc() && mOnPlayClickListener != null && isShowTimeline && mPlayDrawable.getBounds().contains((int) e.getX(), (int) e.getY())) {
+ int centerLine = getCenterLine();
+ long centerLineTime = mLrcEntryList.get(centerLine).getTime();
+ // onPlayClick 消费了才更新 UI
+ if (mOnPlayClickListener != null && mOnPlayClickListener.onPlayClick(LrcView.this, centerLineTime)) {
+ isShowTimeline = false;
+ removeCallbacks(hideTimelineRunnable);
+ mCurrentLine = centerLine;
+ invalidate();
+ return true;
+ }
+ } else if (mOnTapListener != null && !isTouchForStopFling) {
+ mOnTapListener.onTap(LrcView.this, e.getX(), e.getY());
+ }
+ return super.onSingleTapConfirmed(e);
+ }
+ };
+
+ private Runnable hideTimelineRunnable = new Runnable() {
+ @Override
+ public void run() {
+ Log.d(TAG, "hideTimelineRunnable run");
+ if (hasLrc() && isShowTimeline) {
+ isShowTimeline = false;
+ smoothScrollTo(mCurrentLine);
+ }
+ }
+ };
+
+ @Override
+ public void computeScroll() {
+ if (mScroller.computeScrollOffset()) {
+ mOffset = mScroller.getCurrY();
+ invalidate();
+ }
+
+ if (isFling && mScroller.isFinished()) {
+ Log.d(TAG, "fling finish");
+ isFling = false;
+ if (hasLrc() && !isTouching) {
+ adjustCenter();
+ postDelayed(hideTimelineRunnable, TIMELINE_KEEP_TIME);
+ }
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ removeCallbacks(hideTimelineRunnable);
+ super.onDetachedFromWindow();
+ }
+
+ private void onLrcLoaded(List entryList) {
+ if (entryList != null && !entryList.isEmpty()) {
+ mLrcEntryList.addAll(entryList);
+ }
+
+ Collections.sort(mLrcEntryList);
+
+ initEntryList();
+ invalidate();
+ }
+
+ private void initPlayDrawable() {
+ int l = (mTimeTextWidth - mDrawableWidth) / 2;
+ int t = getHeight() / 2 - mDrawableWidth / 2;
+ int r = l + mDrawableWidth;
+ int b = t + mDrawableWidth;
+ mPlayDrawable.setBounds(l, t, r, b);
+ }
+
+ private void initEntryList() {
+ if (!hasLrc() || getWidth() == 0) {
+ return;
+ }
+
+ for (LrcEntry lrcEntry : mLrcEntryList) {
+ lrcEntry.init(mLrcPaint, (int) getLrcWidth(), mTextGravity);
+ }
+
+ mOffset = (float) getHeight() / 2;
+ }
+
+ private void reset() {
+ endAnimation();
+ mScroller.forceFinished(true);
+ isShowTimeline = false;
+ isTouching = false;
+ isFling = false;
+ removeCallbacks(hideTimelineRunnable);
+ mLrcEntryList.clear();
+ mOffset = 0;
+ mCurrentLine = 0;
+ invalidate();
+ }
+
+ /**
+ * 将中心行微调至正中心
+ */
+ private void adjustCenter() {
+ smoothScrollTo(getCenterLine(), ADJUST_DURATION);
+ }
+
+ /**
+ * 滚动到某一行
+ */
+ private void smoothScrollTo(int line) {
+ smoothScrollTo(line, mAnimationDuration);
+ }
+
+ /**
+ * 滚动到某一行
+ */
+ private void smoothScrollTo(int line, long duration) {
+ float offset = getOffset(line);
+ endAnimation();
+
+ mAnimator = ValueAnimator.ofFloat(mOffset, offset);
+ mAnimator.setDuration(duration);
+ mAnimator.setInterpolator(new LinearInterpolator());
+ mAnimator.addUpdateListener(animation -> {
+ mOffset = (float) animation.getAnimatedValue();
+ invalidate();
+ });
+ LrcUtils.resetDurationScale();
+ mAnimator.start();
+ }
+
+ /**
+ * 结束滚动动画
+ */
+ private void endAnimation() {
+ if (mAnimator != null && mAnimator.isRunning()) {
+ mAnimator.end();
+ }
+ }
+
+ /**
+ * 二分法查找当前时间应该显示的行数(最后一个 <= time 的行数)
+ */
+ private int findShowLine(long time) {
+ int left = 0;
+ int right = mLrcEntryList.size();
+ while (left <= right) {
+ int middle = (left + right) / 2;
+ long middleTime = mLrcEntryList.get(middle).getTime();
+
+ if (time < middleTime) {
+ right = middle - 1;
+ } else {
+ if (middle + 1 >= mLrcEntryList.size() || time < mLrcEntryList.get(middle + 1).getTime()) {
+ return middle;
+ }
+
+ left = middle + 1;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * 获取当前在视图中央的行数
+ */
+ private int getCenterLine() {
+ int centerLine = 0;
+ float minDistance = Float.MAX_VALUE;
+ for (int i = 0; i < mLrcEntryList.size(); i++) {
+ if (Math.abs(mOffset - getOffset(i)) < minDistance) {
+ minDistance = Math.abs(mOffset - getOffset(i));
+ centerLine = i;
+ }
+ }
+ return centerLine;
+ }
+
+ /**
+ * 获取歌词距离视图顶部的距离
+ * 采用懒加载方式
+ */
+ private float getOffset(int line) {
+ if (mLrcEntryList.get(line).getOffset() == Float.MIN_VALUE) {
+ float offset = (float) getHeight() / 2;
+ for (int i = 1; i <= line; i++) {
+ offset -= ((mLrcEntryList.get(i - 1).getHeight() + mLrcEntryList.get(i).getHeight()) >> 1) + mDividerHeight;
+ }
+ mLrcEntryList.get(line).setOffset(offset);
+ }
+
+ return mLrcEntryList.get(line).getOffset();
+ }
+
+ /**
+ * 获取歌词宽度
+ */
+ private float getLrcWidth() {
+ return getWidth() - mLrcPadding * 2;
+ }
+
+ /**
+ * 在主线程中运行
+ */
+ private void runOnUi(Runnable r) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ r.run();
+ } else {
+ post(r);
+ }
+ }
+
+ private Object getFlag() {
+ return mFlag;
+ }
+
+ private void setFlag(Object flag) {
+ this.mFlag = flag;
+ }
+}
diff --git a/lrcview/src/main/res/drawable-xxhdpi/lrc_play.png b/lrcview/src/main/res/drawable-xxhdpi/lrc_play.png
new file mode 100644
index 0000000..2513f81
Binary files /dev/null and b/lrcview/src/main/res/drawable-xxhdpi/lrc_play.png differ
diff --git a/lrcview/src/main/res/values/attrs.xml b/lrcview/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..e09df43
--- /dev/null
+++ b/lrcview/src/main/res/values/attrs.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lrcview/src/main/res/values/colors.xml b/lrcview/src/main/res/values/colors.xml
new file mode 100644
index 0000000..e2e3dda
--- /dev/null
+++ b/lrcview/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #9E9E9E
+ #FF4081
+ #F8BBD0
+ #809E9E9E
+ #809E9E9E
+
\ No newline at end of file
diff --git a/lrcview/src/main/res/values/dimens.xml b/lrcview/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..4a5c994
--- /dev/null
+++ b/lrcview/src/main/res/values/dimens.xml
@@ -0,0 +1,10 @@
+
+
+ 1000
+ 16sp
+ 12sp
+ 16dp
+ 1dp
+ 30dp
+ 40dp
+
\ No newline at end of file
diff --git a/lrcview/src/main/res/values/strings.xml b/lrcview/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bd87035
--- /dev/null
+++ b/lrcview/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ 暂无歌词
+