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 @@ + + 暂无歌词 +