更新了歌词组件支持更多操作

This commit is contained in:
muqing 2024-02-02 20:39:47 +08:00
parent f78e19db08
commit 595e887010
13 changed files with 1265 additions and 0 deletions

Binary file not shown.

View File

@ -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"
}

1
lrcview/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

22
lrcview/build.gradle Normal file
View File

@ -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'
}

View File

@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
</manifest>

View File

@ -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<LrcEntry> {
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());
}
}

View File

@ -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<LrcEntry> parseLrc(File[] lrcFiles) {
if (lrcFiles == null || lrcFiles.length != 2 || lrcFiles[0] == null) {
return null;
}
File mainLrcFile = lrcFiles[0];
File secondLrcFile = lrcFiles[1];
List<LrcEntry> mainEntryList = parseLrc(mainLrcFile);
List<LrcEntry> 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<LrcEntry> parseLrc(File lrcFile) {
if (lrcFile == null || !lrcFile.exists()) {
return null;
}
List<LrcEntry> 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<LrcEntry> 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<LrcEntry> parseLrc(String[] lrcTexts) {
if (lrcTexts == null || lrcTexts.length != 2 || TextUtils.isEmpty(lrcTexts[0])) {
return null;
}
String mainLrcText = lrcTexts[0];
String secondLrcText = lrcTexts[1];
List<LrcEntry> mainEntryList = parseLrc(mainLrcText);
List<LrcEntry> 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<LrcEntry> parseLrc(String lrcText) {
if (TextUtils.isEmpty(lrcText)) {
return null;
}
if (lrcText.startsWith("\uFEFF")) {
lrcText = lrcText.replace("\uFEFF", "");
}
List<LrcEntry> entryList = new ArrayList<>();
String[] array = lrcText.split("\\n");
for (String line : array) {
List<LrcEntry> 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<LrcEntry> 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<LrcEntry> 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();
}
}
}

View File

@ -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<LrcEntry> 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<List<LrcEntry>> callable = () -> LrcUtils.parseLrc(new File[]{mainLrcFile, secondLrcFile});
Future<List<LrcEntry>> future = executor.submit(callable);
try {
List<LrcEntry> 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<List<LrcEntry>> callable = () -> LrcUtils.parseLrc(
new String[]{mainLrcText, secondLrcText});
Future<List<LrcEntry>> future = executor.submit(callable);
try {
List<LrcEntry> 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<LrcEntry> 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LrcView">
<attr name="lrcTextSize" format="dimension" />
<attr name="lrcNormalTextSize" format="dimension" />
<attr name="lrcDividerHeight" format="dimension" />
<attr name="lrcNormalTextColor" format="reference|color" />
<attr name="lrcCurrentTextColor" format="reference|color" />
<attr name="lrcTimelineTextColor" format="reference|color" />
<attr name="lrcAnimationDuration" format="integer" />
<attr name="lrcLabel" format="string" />
<attr name="lrcPadding" format="dimension" />
<attr name="lrcTimelineColor" format="reference|color" />
<attr name="lrcTimelineHeight" format="dimension" />
<attr name="lrcPlayDrawable" format="reference" />
<attr name="lrcTimeTextColor" format="reference|color" />
<attr name="lrcTimeTextSize" format="dimension" />
<attr name="lrcTextGravity">
<enum name="center" value="0" />
<enum name="left" value="1" />
<enum name="right" value="2" />
</attr>
</declare-styleable>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="lrc_normal_text_color">#9E9E9E</color>
<color name="lrc_current_text_color">#FF4081</color>
<color name="lrc_timeline_text_color">#F8BBD0</color>
<color name="lrc_timeline_color">#809E9E9E</color>
<color name="lrc_time_text_color">#809E9E9E</color>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="lrc_animation_duration">1000</integer>
<dimen name="lrc_text_size">16sp</dimen>
<dimen name="lrc_time_text_size">12sp</dimen>
<dimen name="lrc_divider_height">16dp</dimen>
<dimen name="lrc_timeline_height">1dp</dimen>
<dimen name="lrc_drawable_width">30dp</dimen>
<dimen name="lrc_time_width">40dp</dimen>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="lrc_label">暂无歌词</string>
</resources>