前言
使用Kotlin实现一个简易的本地音乐客户端,部分代码为Java语言,Kotlin可以完美调用Java,Enjoy it!
三方库
- Glide
- Anko
- BaseRecyclerViewAdapterHelper
- Greendao
环境准备
项目使用的是最新的 Android Studio3.0 版本,新版的Studio只需要在项目新建时勾选“Include Kotlin support”即可导入Kotlin的支持,无需配置复杂环境。
效果图
项目目录
歌词显示
歌词显示主要实现逻辑件位于view文件夹下的ILrcBuilder、ILrcView、LrcRow、LrcView四个文件。在需要实现歌词显示的Activity中添加如下布局文件即可:
其中LrcRow文件下可以设置歌词的相关属性参数,例如歌词文字的大小、行间距、正在播放歌词的颜色等等。
private List mLrcRows; // all lrc rows of one lrc file
private int mMinSeekFiredOffset = 10; // min offset for fire seek action, px;
private int mHignlightRow = 0; // current singing row , should be highlighted.
private int mHignlightRowColor = Color.YELLOW;
private int mNormalRowColor = Color.WHITE;
private int mSeekLineColor = Color.CYAN;
private int mSeekLineTextColor = Color.CYAN;
private int mSeekLineTextSize = 20;
private int mMinSeekLineTextSize = 30;
private int mMaxSeekLineTextSize = 50;
private int mLrcFontSize = 50; // font size of lrc
private int mMinLrcFontSize = 10;
private int mMaxLrcFontSize = 40;
private int mPaddingY = 43; // padding of each row
private int mSeekLinePaddingX = 0; // Seek line padding x
private int mDisplayMode = DISPLAY_MODE_NORMAL;
private LrcViewListener mLrcViewListener;
注:后续扩展在线歌词,歌词路径暂时写死在手机根目录,文件名为“林中鸟.lrc”。
扫描音乐
扫描音乐工具类
扫描音乐的实现逻辑如下,DISPLAY_NAME、TITLE、ARTIST、DATA、SIZE、DURATION分别对应了扫描音乐的外部可重命名的文件名、内部音乐文件名、歌手名称、文件所在路径、歌曲的大小、歌曲的时长信息。获取后可用于后续自定义扫描码、歌曲播放设置等处。isAudioControlPanelAvailable()方法是用来设置音效用的,后续会说到。
object MusicUtils {
/**
* 扫描系统里面的音频文件,返回一个list集合
*/
fun getMusicData(context: Context): ArrayList {
val filter_size = PreferenceManager.getDefaultSharedPreferences(context).getString("filter_size", "0")
val filter_time = PreferenceManager.getDefaultSharedPreferences(context).getString("filter_time", "0")
val list = ArrayList()
// 媒体库查询语句(写一个工具类MusicUtils)
val cursor = context.contentResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, MediaStore.Audio.AudioColumns.IS_MUSIC)
if (cursor != null) {
while (cursor.moveToNext()) {
val music: Music? = Music()
// music!!.song = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME))
music!!.song = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))
music.singer = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST))
music.path = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA))
music.size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE))
music.duration = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION))
if (music.size.toInt()/1000 > filter_size.toInt() || music.duration!!.toInt()/1000 > filter_time.toInt()) {
// 注释部分是切割标题,分离出歌曲名和歌手 (本地媒体库读取的歌曲信息不规范)
if (music.song!!.contains("-")) {
val str = music.song!!.split("-".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
music.singer = str[0]
music.song = str[1]
}
list.add(music)
}
}
// 释放资源
cursor.close()
}
return list
}
fun isAudioControlPanelAvailable(context: Context): Boolean {
return isIntentAvailable(context, Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL))
}
private fun isIntentAvailable(context: Context, intent: Intent): Boolean {
return context.packageManager.resolveActivity(intent, PackageManager.GET_RESOLVED_FILTER) != null
}
}
扫描音乐动画
点击扫描音乐可实现雷达动画,音乐扫描完毕动画结束,实现扫描代码:
fun scanMusic() {
val degrees = ObjectAnimator.ofInt(sv, "degrees", 0, 360)
degrees.interpolator = LinearInterpolator()
degrees.duration = 1000
degrees.repeatCount = ValueAnimator.INFINITE
degrees.start()
async({
MyApplication.instance!!.getMusicDao().deleteAll()
for (i in 0..getMusicData(MyApplication.instance!!).size - 1) {
val list = Music(i.toLong(), getMusicData(MyApplication.instance!!).get(i).song, getMusicData(MyApplication.instance!!).get(i).singer, getMusicData(MyApplication.instance!!).get(i).path)
MyApplication.instance!!.getMusicDao().insertInTx(list)
}
runOnUiThread {
degrees.cancel()
Toast.makeText(MyApplication.instance!!,"已扫描完毕",Toast.LENGTH_SHORT).show()
}
})
}
动画效果代码如下:
public class ShaderView extends View {
/**
* 绘制扫描圈的笔
*/
private Paint mSweepPaint;
/**
* 绘制背景bitmap的笔
*/
private Paint mBitmapPaint;
/**
* 这个自定义View的宽度,就是你在xml布局里面设置的宽度(目前不支持)
*/
private int mWidth;
/**
* 背景图片
*/
private Bitmap mBitmap;
/**
* 雷达扫描旋转角度
*/
private int degrees = 0;
/**
* 用于控制扫描圈的矩阵
*/
Matrix mSweepMatrix = new Matrix();
/**
* 用于控制背景Bitmap的矩阵
*/
Matrix mBitmapMatrix = new Matrix();
/**
* 着色器---生成扫描圈
*/
private SweepGradient mSweepGradient;
/**
* 图片着色器
*/
private BitmapShader mBitmapShader;
private float mScale;
public ShaderView(Context context) {
super(context);
init();
}
public ShaderView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
/**
* 属性动画,必须有setXxx方法,才可以针对这个属性实现动画
*
* @param degrees
*/
public void setDegrees(int degrees) {
this.degrees = degrees;
postInvalidate();//在主线程里执行OnDraw
}
private void init() {
// 1.准备好画笔
mSweepPaint = new Paint();
mBitmapPaint = new Paint();
// 2.图片着色器
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.scan_music);
mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
// 3.将图片着色器设置给画笔
mBitmapPaint.setShader(mBitmapShader);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获取这个自定义view的宽高,注意在onMeasure里获取,在构造函数里得到的是0
mWidth = getMeasuredWidth();
// 根据你所设置的view的尺寸和bitmap的尺寸计算一个缩放比例,否则的话,得到的图片是一个局部,而不是一整张图片
mScale = (float) mWidth / (float) mBitmap.getWidth();
// 4.梯度扫描着色器
mSweepGradient = new SweepGradient(mWidth / 2, mWidth / 2, new int[]{Color.argb(50, 0, 0, 100), Color.argb(10, 30, 0, 0)}, null);
// 5.将梯度扫描着色器设置给另外一支画笔
mSweepPaint.setShader(mSweepGradient);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 迫不得已的时候,才在onDraw方法写代码,能提前准备的要在之前去准备,
// 不要写在onDraw里面,因为onDraw会不停地刷新绘制,写的代码越多,越影响效率
// 将图片缩放至你指定的自定义View的宽高
mBitmapMatrix.setScale(mScale, mScale);
mBitmapShader.setLocalMatrix(mBitmapMatrix);
// 设置扫描圈旋转角度
mSweepMatrix.setRotate(degrees, mWidth / 2, mWidth / 2);
mSweepGradient.setLocalMatrix(mSweepMatrix);
// 5. 使用设置好图片着色器的画笔,画圆,先画出下层的背景图片,在画出上层的扫描图片
canvas.drawCircle(mWidth / 2, mWidth / 2, mWidth / 2, mBitmapPaint);
canvas.drawCircle(mWidth / 2, mWidth / 2, mWidth / 2, mSweepPaint);
}
}
实现音效设置
上文提到的isAudioControlPanelAvailable()方法在设置中的音效处调用。AudioSessionId获取当前播放的音乐sessionid,如果将AudioSessionId设为0,则所有在播放的音频都会被影响到。
调用代码:
private fun startEqualizer() {
if (MusicUtils.isAudioControlPanelAvailable(activity)) {
val intent = Intent()
val packageName = activity.packageName
intent.action = AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL
intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName)
intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, AudioSessionId)
try {
startActivityForResult(intent, 1)
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
Toast.makeText(context, "设备不支持", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, "设备不支持", Toast.LENGTH_SHORT).show()
}
}
定时停止播放
音乐播放器一般会实现定时停止播放功能,向TimeStop()方法中输入需要定时的参数。
代码如下:
fun TimeStop(time: Long) {
if (time.toInt() == 0 && job == null) {
Toast.makeText(this, "停止播放功能未开启", Toast.LENGTH_SHORT).show()
} else if (time.toInt() != 0) {
val time1 = time * 60000
Toast.makeText(this, "$time 分钟后停止播放", Toast.LENGTH_SHORT).show()
job = launch(CommonPool) {
delay(time1, TimeUnit.MILLISECONDS)
playService!!.pause()
}
} else {
job!!.cancel()
Toast.makeText(this, "已取消定时停止播放", Toast.LENGTH_SHORT).show()
}
}
歌曲控制
暂停播放等不在此赘述,具体可看源码,控制上一首下一首使用了Greendao数据库,扫码歌曲时,数据填入数据库中,下面是返回上一首的实现代码,下一首实现功能类似,使用Greendao主要是用来练练手,没有什么特别的原因。
fun previous() {
if (position == 0) {
num = MyApplication.instance!!.getMusicDao().queryBuilder().list().size.plus(-1)
path = MyApplication.instance!!.getMusicDao().queryBuilder().where(MusicDao.Properties.Id.eq(num)).list()
} else {
num = position!!.plus(-1)
path = MyApplication.instance!!.getMusicDao().queryBuilder().where(MusicDao.Properties.Id.eq(num)).list()
}
prepare((path as MutableList?)!!.get(0).path)
start()
position = num
val intent = Intent("com.nilin.etherealmusic.play")
intent.putExtra("song", (path as MutableList?)!!.get(0).song)
intent.putExtra("singer", (path as MutableList?)!!.get(0).singer)
sendBroadcast(intent)
val editor = MyApplication.instance!!.getSharedPreferences("music_pref", Context.MODE_PRIVATE).edit()
editor.putString("song", (path as MutableList?)!!.get(0).song)
editor.putInt("position", position!!)
editor.apply()
}
启动引导页动画、搜索后音乐列表显示、音乐进度条拖拽快进、扫描音乐条件控制等不多赘述。功能实现并不复杂,为后续添加网络音乐功能做准备,部分功能暂未实现,后续会更新完善。