Android MAD HW3 仿网易云播放器
一、项目说明
本项目为2021学年移动应用开发课程第三次作业,要求是实现一个仿网易云音乐播放器,本想提前做的,还是拖到检查前一天才开始写,就写一下文档记录一下一个通宵肝完的音乐播放器吧
项目仓库:仿网易云播放器demo
二、作业要求&实现
-
实现具有以上布局样式的音乐播放器
-
播放器具有正常的播放、暂停、停止、继续、退出功能,按停止键会重置封面转角,进度条和播放按钮;按退出键将停止播放并退出程序
-
后台播放功能,按手机的返回键和 home 键都不会停止播放,而是转入后台进行播放
-
进度条能正确显示播放进度、拖动进度条改变进度功能
-
显示当前播放时间功能,播放时图片旋转(圆形图片的实现使用的是一个开源控件 CircleImageView)
-
可以从本地存储中读取音乐资源进行播放
-
加分项:在保持上述原有的功能的情况下,使用 rxJava 代替 Handler 进行 UI 的更新
具体实现效果:
- 实现作业要求中所有功能
- 使用RxJava替代Handler实现异步操作进行UI更新
- 使用BroadCast进行Activity与Service之间通信
- 实现背景显示歌曲封面,同时背景模糊(参照网易云效果实现
- 实现点击 …… 后弹出自定义Dialog,显示未被添加的本地音乐,同时添加支持单选多选全选模式,长按进入多选模式,同时页面图片使用Glide缓存加载
- 实现完全沉浸模式,不显示导航栏和底部虚拟按键栏(同游玩全屏游戏王者、吃鸡类似,滑动底部屏幕显示虚拟按钮,其他情况下应用全屏显示
- 实现滑动切歌功能,通过重写viewpager的onpagechangelistener实现
三、实现思路
- UI界面
- 大体依照网易云界面,由于项目要求有本地文件播放功能,和退出按钮,就考虑在原有播放键两端或者原有评论位置替换
- 大体部件包括:旋转的CD,播放、暂停按钮,文件选择,退出按钮,上方toolbar
- toolBar:显示歌曲名称和歌手,右侧分享按钮
- 歌曲名称和歌手通过读取音频文件获取
- CD旋转:
- 用助教要求的CircleImageView实现,旋转用Animation来做吧(应该
- 切换用重写一个PageView来实现,通过重写OnPageChangeListener 对滑动进行判断来进行页面切换
- SeekBar:
- 通过Activity和service进行broadcast通信通知seekbar改变,同时在设定handler作为定时器进行seekbar的更新操作
- 播放暂停:
- 调用MediaPlayer的播放功能
- 歌曲切换:
- 模仿网易云功能实现
- 文件读取:
- 读取本地音乐文件播放
- 退出
- 没啥好说的,直接退出activity即可
- 后端运行:
- service
- Activity与Service通信:
- 用BroadCast实现
- 所有异步数据更新操作:
- RxJava实现
- 弹出自定义dialog实现添加音乐:
- 重写BaseAdapter,以及为dialog添加监听
- 拓展:
- 背景模糊、沉浸模式、点击弹出列表选择音乐加入(仿网易云)、实现单选多选全选加入音乐
四、成果展示
基础页面1 | 基础页面2 | 滑动下方屏幕显示虚拟按键 |
---|---|---|
![]() | ![]() | ![]() |
歌曲单选添加 | 歌曲全选 | 歌曲多选添加 |
![]() | ![]() | ![]() |
五、目录结构
六、具体实现
获取权限
因为实现音乐播放器要用到存储的读取权限,所以我们要改写Manifest文件
-
加入所需权限
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS" tools:ignore="ProtectedPermissions" />
-
安卓10.0之后需要在application中加入
android:requestLegacyExternalStorage="true"
才能访问外部存储,否则只能播放系统自带音频,不能播放网易云/qq音乐下载的音乐文件(划重点!!!!)
初始化BroadCastReceiver
初始化广播接受器,过滤接受对应的广播类型,用于activity和Service之间数据传输
private void initBroadcastReceiver(){
IntentFilter filter = new IntentFilter();
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_LIST_EMPTY);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_PROCESS);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_DURATION);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_PLAY_OVER);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_PAUSE);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_LAST);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_PLAY);
filter.addAction(BroadType.SERVICE2ACTIVITY_MUSIC_NEXT);
LocalBroadcastManager.getInstance(this).registerReceiver(mainBroadcastReceiver,filter);
}
初始化View
-
首先初始化播放其页面所要用的view视图,并为seekbar添加监听:
- 其中当停止拖拽时,调用handler为seekbar进行每秒的定时更新
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { curr_time.setText(duration2Time(progress)); } @Override public void onStartTrackingTouch(SeekBar seekBar) { timeHandler.removeMessages(0x1); } @Override public void onStopTrackingTouch(SeekBar seekBar) { sendBroadCast(BroadType.MUSIC_SEEKBAR_CHANGE,seekBar.getProgress()); timeHandler.sendEmptyMessageDelayed(0x1,1000); } });
-
之后为viewpager添加自定义PageChangeListener
- 监听viewpager的滑动状态以及设定页面切换后的逻辑动作,如:更新toolbar、背景、歌曲等
- 主要通过onPageScoll中对滑动Offset 进行判断左滑or右滑
- 以及在onPageSelected中进行判断页面切换
viewPager.addOnPageChangeListener(MyPageChangeListener); @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { //左滑 if (lastPositionOffsetPixels > positionOffsetPixels) { if (positionOffset < 0.5) { changeViewPagerPage(position); } else { changeViewPagerPage(viewPager.getCurrentItem()); } } //右滑 else if (lastPositionOffsetPixels < positionOffsetPixels) { if (positionOffset > 0.5) { changeViewPagerPage(position + 1); } else { changeViewPagerPage(position); } } lastPositionOffsetPixels = positionOffsetPixels; } @Override public void onPageSelected(int position) { //更新背景图 实现渐变 setBackGroundView(position); //重置动画----------------------------------------------------------------------- animationList.get(position).start(); //更新上方标题作者信息 updateBarInfo(position); if (position > currentItem){ sendBroadCast(BroadType.MUSIC_NEXT,null); }else { sendBroadCast(BroadType.MUSIC_LAST,null); } currentItem = position; }
初始化音乐数据
这里因为我设定默认一开始拉取本地三首音乐进播放器,所以一开始要搜索本地音乐并加入Service
这里通过调用Util中的LocalMusicUitls来获取本地存储中的音乐,在获取List完成之后,使用RxJava 进行异步处理,对每一个Song调用addPageViewPage函数进行加载新的view以及将Song数据存储到列表中,之后使用列表数据更新viewpager实现数据加入
//歌曲数据存储列表
private List<Song> musicDataList = new ArrayList<>();
//viewpager 视图列表
private List<View> viewList = new ArrayList<>();
private void initMusicData(){
List<Song> tem_SongList = LocalMusicUtils.getmusic(this);
showLog("总音乐数:" +tem_SongList.size() + "");
while (tem_SongList.size() > 3){
tem_SongList.remove(0);
}
sendRxJavaSongList(tem_SongList);
}
private void sendRxJavaSongList(List<Song> songs){
Observable<Song> observable = Observable.fromIterable(songs);
observable.subscribe(songObserver);
}
根据音乐数据更新viewpager
在初始化音乐数据完成后,RxJava 的观察者调用onNext来通过addPageViewPage实现页面view、animation、Drawable的更新,在onComplete中刷新adapter实现viewpager的更新,在首次初始化音乐数据后,调用songObserver,在oncomplete中进行Service的初始化
-
通过song数据读取对应的歌曲封面等
private void addPageViewPage(Song song){ musicDataList.add(song); View add_view = LayoutInflater.from(this).inflate(R.layout.page_layout,viewPager,false); CircleImageView circleImageView = add_view.findViewById(R.id.cd_image_view); Bitmap album_bitmap = LocalMusicUtils.getArtwork(this,song.id,song.albumId,true,false); Drawable drawable = new BitmapDrawable(getResources(),album_bitmap); circleImageView.setImageDrawable(drawable); animationList.add(getDiscObjectAnimator(circleImageView)) ; viewList.add(add_view); }
在createActivity的最后,因此虚拟按键,并设置全屏
/**
* 隐藏虚拟按键,并且全屏
*/
protected void hideBottomUIMenu() {
//隐藏虚拟按键,并且全屏
if (Build.VERSION.SDK_INT > 11 && Build.VERSION.SDK_INT < 19) { // lower api
View v = this.getWindow().getDecorView();
v.setSystemUiVisibility(View.GONE);
} else if (Build.VERSION.SDK_INT >= 19) {
//for new api versions.
View decorView = getWindow().getDecorView();
int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
decorView.setSystemUiVisibility(uiOptions);
}
}
Service的初始化
首先在Activity中初始化完3首歌曲信息之后,在RxJava的Observer Oncomplete回调中调用initService
使用Intent方法,将歌曲信息List传递给Service
//start service
Intent intent = new Intent(this, MusicService.class);
intent.putExtra(FlagMessage.SERVICE_SET_PARAM, (Serializable)musicDataList);
startService(intent);
在Service中,首先我们了解Service并不是在startService 的intent传递时创建,而是在startservice时调用onstartCommand,开始工作,所以我们首先在onCreate中初始化BroadCast广播通信,在onStartCommand中提取对应的歌曲信息,之后调用封装好的MediaPlay的play函数开始播放
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null){
List<Song> songList1 = (ArrayList<Song>) intent.getSerializableExtra(FlagMessage.SERVICE_SET_PARAM);
for (Song song: songList1){
showLog("song: "+ song.name);
}
songList = songList1;
for (Song song: songList){
showLog("Songlist item : " + song.name);
}
showLog("songlist size : "+ songList.size());
}
showLog("start Command");
play(0);
return super.onStartCommand(intent, flags, startId);
}
歌曲播放等逻辑
为了实现基础歌曲播放,暂停下一首,上一首功能,我们要在Activity和Sercive中分别实现对应的play、stop、next、last、seekbarchange函数
Servie中,通过BroadcastReceiver接受对应信号来调用对应逻辑函数:
private BroadcastReceiver musicBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
showLog("Service receive BoardCast : "+ intent.getAction());
switch (intent.getAction()){
case BroadType.MUSIC_LAST:
last();
break;
case BroadType.MUSIC_NEXT:
next();
break;
case BroadType.MUSIC_PLAY:
play(currentMusicPosition);
break;
case BroadType.MUSIC_SEEKBAR_CHANGE:
seekBarChange(intent);
break;
case BroadType.MUSIC_ADD_SONGS:
List<Song> songList1 = (ArrayList<Song>) intent.getSerializableExtra(BroadType.MUSIC_ADD_SONGS);
songList.addAll(songList1);
showLog("歌曲数量增加,现在的数量为" + songList.size());
break;
}
}
};
-
Service:
-
play:
-
根据position是否为当前页面和是否时播放状态,决定发出播放、暂停、切歌等对应的广播消息,并执行相关函数
private void play(final int position) { showLog("Service play: "+ position ); if(position >= songList.size()){ sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_LIST_EMPTY); return; } if(currentMusicPosition == position){ if (mMediaPlayer == null){ mMediaPlayer = new MediaPlayer(); String tem = songList.get(position).path.replaceAll(" ","\\ "); // File musicFile = new File(songList.get(position).path); File musicFile = new File(tem); showLog("songList.size() :" + songList.size()+ "get position:" + songList.get(position).singer + " getPath: " + tem); mMediaPlayer = MediaPlayer.create(getApplicationContext(), Uri.fromFile(musicFile)); mMediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { mMediaPlayer.start(); mMediaPlayer.setOnCompletionListener(MusicService.this); currentMusicPosition = position; currentSongPosition = 0; sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_DURATION); sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_PLAY); } }); } else if (mMediaPlayer.isPlaying()){ //记录暂停时位置 currentSongPosition = mMediaPlayer.getCurrentPosition(); mMediaPlayer.pause(); sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_PAUSE); }else { if (currentSongPosition!= 0){ //从暂停位置开始播放 mMediaPlayer.seekTo(currentSongPosition); mMediaPlayer.setOnSeekCompleteListener(new MediaPlayer.OnSeekCompleteListener() { @Override public void onSeekComplete(MediaPlayer mp) { mMediaPlayer.start(); } }); }else { mMediaPlayer.start(); } sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_PLAY); } }else { //歌曲切换情况 showLog("play another music"); if (mMediaPlayer != null){ mMediaPlayer.stop(); } mMediaPlayer = null; File musicFile = new File(songList.get(position).path); showLog("歌曲path: " + songList.get(position).path); mMediaPlayer = MediaPlayer.create(getApplicationContext(), Uri.fromFile(musicFile)); mMediaPlayer.start(); mMediaPlayer.setOnCompletionListener(MusicService.this); currentMusicPosition = position; currentSongPosition = 0; sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_DURATION); sendBroadcastMessage(BroadType.SERVICE2ACTIVITY_MUSIC_PLAY); } }
-
next:调用play
-
last:调用play
-
seekBarchange:
- 向activity发送seekbarchange的信号,并且设置MediaPlayer播放对应progress位置音频
private void seekBarChange(Intent intent){ if (mMediaPlayer.isPlaying()){ int position = intent.getIntExtra(BroadType.MUSIC_SEEKBAR_CHANGE,0); mMediaPlayer.seekTo(position); }else { Intent i = new Intent(BroadType.SERVICE2ACTIVITY_MUSIC_PROCESS); i.putExtra(BroadType.SERVICE2ACTIVITY_MUSIC_PROCESS,mMediaPlayer.getCurrentPosition()); LocalBroadcastManager.getInstance(this).sendBroadcast(i); } }
-
-
-
Activity
-
逻辑函数通过点击button以及收到Service的歌曲播放暂停广播调用
-
play:播放页面旋转动画,设置图片转动,开始更新seekbar
private void play(){ animationList.get(viewPager.getCurrentItem()).resume(); button_play.setImageResource(R.drawable.ic_pause); startUpdateSeekBar(); }
-
pause:与play相反,暂停
-
next/last:通过对应button点击触发,通过viewpager的setcurrentItem实现页面切换
-
private void last(){ stopUpdateSeekBarProgree(); int currentItem = viewPager.getCurrentItem(); if (currentItem == 0){ Toast.makeText(this,"已经是第一个了",Toast.LENGTH_SHORT).show(); }else { viewPager.setCurrentItem(currentItem - 1,false); } }
-
-
-
Activity页面切换:
-
当调用next、last函数时,通过viewpager的setcurrentItem触发listener的onPageSelected回调,进行上方标题栏、歌曲、背景图等的更新
@Override public void onPageSelected(int position) { //更新背景图 实现渐变 setBackGroundView(position); //重置动画----------------------------------------------------------------------- animationList.get(position).start(); //更新上方标题作者信息 updateBarInfo(position); if (position > currentItem){ sendBroadCast(BroadType.MUSIC_NEXT,null); }else { sendBroadCast(BroadType.MUSIC_LAST,null); } currentItem = position; }
-
背景更新
更新背景,并设置高斯模糊,实现毛玻璃效果
-
首先根据position获取歌曲列表中对应歌曲信息:并用于获取图片
private void setBackGroundView(int position){ View viewAtPosition = viewList.get(position); Bitmap album_bitmap = LocalMusicUtils.getArtwork(this,musicDataList.get(position).id,musicDataList.get(position).albumId,true,false); Bitmap deal_bitmap = blurBitmap(album_bitmap,25); Drawable drawable = new BitmapDrawable(getResources(),deal_bitmap); root_layout.setBackground(drawable); root_layout.invalidate(); }
-
调用blurBitmap进行高斯模糊,实现毛玻璃效果
public Bitmap blurBitmap(Bitmap bitmap, int radius) { //创建一个空bitmap,其大小与我们想要模糊的bitmap大小相同 Bitmap outBitmap = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); //实例化一个新的Renderscript RenderScript rs = RenderScript.create(getApplicationContext()); //创建Allocation对象 Allocation allIn = Allocation.createFromBitmap(rs, bitmap); Allocation allOut = Allocation.createFromBitmap(rs, outBitmap); //创建ScriptIntrinsicBlur对象,该对象实现了高斯模糊算法 ScriptIntrinsicBlur blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); //设置模糊半径,0 <radius <= 25 blurScript.setRadius(radius); //执行Renderscript blurScript.setInput(allIn); blurScript.forEach(allOut); //将allOut创建的Bitmap复制到outBitmap allOut.copyTo(outBitmap); //释放内存占用 bitmap.recycle(); //销毁Renderscript。 rs.destroy(); return outBitmap; }
Dialog的歌曲添加
使用自定义视图包含Listview实现歌曲显示,通过重写BaseAdapter实现歌曲的数据存储和选中状态,通过listview的监听实现长按进入多选状态和全选等,
-
创建dialog:
- 使用Util的辅助函数,获取所有歌曲信息,去除掉已经加入的歌曲,将其他歌曲显示在dialog的listview中,为listview实现点击和长按监听,进行选择确认和长按模式改变
-
使用自定义Adapter
-
MyAdapter中,通过HashMap来存储选中状态,在getView中设定Item的数据信息,使用Glide进行图片和歌曲加载
@Override public View getView(int position, View convertView, ViewGroup parent) { if (convertView == null){ holder = new ViewHolder(); convertView = inflater.inflate(R.layout.listitem,null); holder.cb = (CheckBox) convertView.findViewById(R.id.X_checkbox); holder.tv = (TextView) convertView.findViewById(R.id.X_item_text); holder.iv = (ImageView) convertView.findViewById(R.id.list_item_image); holder.tp = (TextView) convertView.findViewById(R.id.X_type); convertView.setTag(holder); }else { holder = (ViewHolder) convertView.getTag(); } //set content Song model = dataSource.get(position); holder.tp.setText(model.name); holder.tv.setText(model.singer); holder.iv.setImageBitmap(LocalMusicUtils.getArtwork(context,dataSource.get(position).id,dataSource.get(position).albumId,true,false)); showOrHideCheckBox(); holder.cb.setChecked(getIsSelected().get(position)); return convertView; }
-
-
在选择完毕后进行数据加入,并更新viewpager和Service中的歌曲数据:
-
数据加入通过RxJava进行异步处理,UI更新
private void sendRxJavaSongAdd(List<Song> songs){ Observable<Song> observable = Observable.fromIterable(songs); observable.subscribe(observer); updateService(songs); }
observer中onnext 调用addpageviewpager函数,见上方之前叙述,onComplete调用myAdapter的notifydatachange,刷新viewpager
-
updateService中更新歌曲信息,通过广播传递数据
private void updateService(List<Song> songs){ Intent intent = new Intent(BroadType.MUSIC_ADD_SONGS); intent.putExtra(BroadType.MUSIC_ADD_SONGS,(Serializable) songs); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); }
-
旋转动画
一个ObjectAnimation动画,跟随view创建而创建,并在list中对应存储,在调用时更新restart状态,在play、pause中实现stop、resume
/** * 设置唱片旋转动画 * @param disc * @return */ private ObjectAnimator getDiscObjectAnimator(ImageView disc) { ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(disc, View.ROTATION, 0, 360); objectAnimator.setRepeatCount(ValueAnimator.INFINITE); objectAnimator.setDuration(20 * 1000); objectAnimator.setInterpolator(new LinearInterpolator()); return objectAnimator; }
思路参考
- 页面逻辑:Android 仿网易云音乐播放器
- 本地音乐获取Util :Android 获取本地音乐