前言
第一次写技术博客,首发安卓巴士。使用Kotlin实现一个简易的本地音乐客户端,部分代码为Java语言,Kotlin可以完美调用Java,Enjoy it!
三方库
Glide
Anko
BaseRecyclerViewAdapterHelper
Greendao
环境准备
项目使用的是最新的 Android Studio3.0 版本,新版的Studio只需要在项目新建时勾选“Include Kotlin support”即可导入Kotlin的支持,无需配置复杂环境。
效果图
项目目录
歌词显示
歌词显示主要实现逻辑件位于view文件夹下的ILrcBuilder、ILrcView、LrcRow、LrcView四个文件。在需要实现歌词显示的Activity中添加如下布局文件即可:
<com.nilin.etherealmuisc.view.LrcView android:id="@+id/lrcView" android:layout_width="match_parent" android:layout_height="0dip" android:layout_weight="1" />
其中LrcRow文件下可以设置歌词的相关属性参数,例如歌词文字的大小、行间距、正在播放歌词的颜色等等。
private List<LrcRow> 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<Music> { val filter_size = PreferenceManager.getDefaultSharedPreferences(context).getString("filter_size", "0") val filter_time = PreferenceManager.getDefaultSharedPreferences(context).getString("filter_time", "0") val list = ArrayList<Music>() // 媒体库查询语句(写一个工具类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<Music>?)!!.get(0).path) start() position = num val intent = Intent("com.nilin.etherealmusic.play") intent.putExtra("song", (path as MutableList<Music>?)!!.get(0).song) intent.putExtra("singer", (path as MutableList<Music>?)!!.get(0).singer) sendBroadcast(intent) val editor = MyApplication.instance!!.getSharedPreferences("music_pref", Context.MODE_PRIVATE).edit() editor.putString("song", (path as MutableList<Music>?)!!.get(0).song) editor.putInt("position", position!!) editor.apply() }
启动引导页动画、搜索后音乐列表显示、音乐进度条拖拽快进、扫描音乐条件控制等不多赘述。功能实现并不复杂,为后续添加网络音乐功能做准备,部分功能暂未实现,后续会更新完善。