效果视频
简介
Github上有位牛人将网易云音乐的接口进行部署和总结,然后我将它的仓库部署到我的云服务器上,因为他的是需要翻墙的,此项目所有接口信息均与网易云音乐关联。
由于此项目用于熟悉kotlin语言,所以绝大部分代码均使用kotlin编写;
目前EasyMusic为第一版,功能上还有欠缺,但主流程已经基本完成;
技术栈
- 网络:Okhttp+GSON
- 存储:Room数据库+SharedPreferences
- UI:Material Design,ViewBinding
- 其余:EventBus,Glide...
实现功能
- 最近播放(歌曲,歌单,专辑,mv)
- 播放列表
- 歌曲播放(折叠播放、播放详情页)
- 歌词
- 搜索(历史搜索记录、搜索匹配列表、歌曲,歌单,专辑,mv)
- 榜单(官方榜,精选榜)
- 推荐(歌曲,歌单,mv)
- 登录(账号密码、手机验证码、二维码)
具体实现
首页
效果
代码
DrawerLayout+SlidingUpPanelLayout+BottomNavigationView+NavigationView
UI布局大致如上,一个侧边栏,一个底部导航栏,唯一值得注意的是SlidingUpPanelLayout
,这个一个可滑动的菜单栏,但使用的时候注意其滑动冲突,向上滑动呼出菜单栏,然后显示歌词、歌曲信息等,由于歌词也需要滑动,但由于SlidingUpPanelLayout
是父布局,滑动焦点在它身上,所有歌词和进度条无法滑动,一滑动或点击就导致SlidingUpPanelLayout
为折叠状态,所有将它的滑动事件禁用,并将滑动焦点交给FragmentLayout
,也就是所显示的界面。这样可以避免滑动冲突,但是也少了滑动呼出SlidingUpPanelLayout
,就通过点击折叠栏的图像去展开SlidingUpPanelLayout
禁用滑动
binding.slidUpLayout.isTouchEnabled = false
SlidingUpPanelLayout
的展开和折叠
R.id.songsCover-> binding.slidUpLayout.panelState = SlidingUpPanelLayout.PanelState.EXPANDED
R.id.detailExit-> binding.slidUpLayout.panelState = SlidingUpPanelLayout.PanelState.COLLAPSED
播放
效果
代码
播放的开始、暂停、上一首、下一首、进度回调等都封装在一个单例Binder中,,此Binder的服务在启动时就默认开启,通过在歌单中点击播放全部
按钮,将所在的歌单的所有歌曲添加的room数据库(播放列表)中,然后上一首、下一首则通过下标去进行歌曲切换,背景cover和缩略cover采用了高斯模糊处理和圆角处理。歌词部分采用的是一个第三方开源库
object MusicBinder : Binder() {
private val TAG: String = "MusicBinderLog"
/**
* 当前播放歌曲在播放列表中的位置*/
var curSongsCur: Int = 0
private var curPosition: Long = 0
private var duration: Long = 0
var isPlay: Boolean = false
lateinit var bean: SongBean
private lateinit var player: MediaPlayer
private lateinit var listener: ProgressStatus
private val runnable = object : Runnable {
override fun run() {
if (isPlay) {
curPosition += 1000
listener.getCurrentProgress(isPlay, player.currentPosition.toLong(), duration)
}
}
}
private val scheduled = Executors.newSingleThreadScheduledExecutor()
init {
player = MediaPlayer()
val jsonBean =
SPUtil.getInstance().GetData(BaseApplication.context, CacheKeyParam.recordBean, "")
if (!TextUtils.isEmpty(jsonBean.toString())) {
val jsonObject = JSONObject(jsonBean.toString())
val bean: SongBean = HttpUtil.fromJson(jsonObject.toString(), SongBean::class.java)
this.bean = bean
//player.setDataSource(bean.songUrl)
}
player.setOnPreparedListener {
Log.d(TAG, "准备完成")
duration = player.duration.toLong()
curPosition = 0
play()
}
player.setOnCompletionListener {
next(curSongsCur + 1)
}
player.setOnErrorListener { mp, what, extra ->
stop()
false
}
}
/**
* 准备播放,传入播放地址*/
fun prepare(bean: SongBean) {
Log.d(TAG, "准备播放")
this.bean = bean
try {
player.stop()
player.reset()
player.setAudioStreamType(AudioManager.STREAM_MUSIC)
player.setDataSource(bean.songUrl)
player.prepareAsync()
} catch (e: IllegalStateException) {
e.printStackTrace()
}
}
/**
* 开始播放*/
fun play() {
Log.d(TAG, "开始播放")
player.start()
isPlay = true
EventBus.getDefault().postSticky(this.bean)
scheduled.scheduleAtFixedRate(runnable, 0, 1000, TimeUnit.MILLISECONDS)
SPUtil.getInstance().PutData(BaseApplication.context, CacheKeyParam.recordId, curSongsCur)
SPUtil.getInstance().PutData(
BaseApplication.context,
CacheKeyParam.recordBean,
Gson().toJson(this.bean).toString()
)
}
/**
* 暂停播放,保留当前seek*/
fun pause() {
Log.d(TAG, "暂停播放")
player.pause()
isPlay = false
EventBus.getDefault().postSticky(PlayProgressBean(false, curPosition, duration))
}
/**
* 停止播放,清空播放状态*/
fun stop() {
player.stop()
isPlay = false
EventBus.getDefault().postSticky(PlayProgressBean(false, curPosition, duration))
}
/**
* 读取播放列表下一首歌曲,如果已是最后一首,则播放第一首*/
fun next(pos: Int) {
val db = PlayListDataBase.getDBInstance().playListDao()
val songs = db.getAll()
if (songs.isEmpty()) {
ToastUtil.setFailToast(HomePageActivity.MA, "当前播放列表暂无数据!")
return
}
val position = if (songs.size - 1 >= pos)
pos
else
0
curSongsCur = position
val bean = db.findByMainId(position)
getSongUrl(bean.songId.toString(), object : IGenerallyInfo {
override fun onRespond(json: String?) {
isPlay = true
bean.songUrl = json
prepare(bean)
}
override fun onFailed(e: String?) {
ToastUtil.setFailToast(HomePageActivity.MA, "歌曲播放错误!")
}
})
}
/**
* 读取播放列表上一首歌曲,如果已是第一首,则播放最后一首*/
fun previous(pos: Int) {
val db = PlayListDataBase.getDBInstance().playListDao()
val songs = db.getAll()
if (songs.isEmpty()) {
ToastUtil.setFailToast(HomePageActivity.MA, "当前播放列表暂无数据!")
return
}
val position = if (pos >= 0)
pos
else
songs.size - 1
curSongsCur = position
val bean = db.findByMainId(position)
getSongUrl(bean.songId.toString(), object : IGenerallyInfo {
override fun onRespond(json: String?) {
isPlay = true
bean.songUrl = json
prepare(bean)
}
override fun onFailed(e: String?) {
ToastUtil.setFailToast(HomePageActivity.MA, "歌曲播放错误!")
}
})
}
/**
* 当前是否正在播放*/
fun isPlaying(): Boolean {
return isPlay
}
/**
* 歌曲总时长*/
fun duration(): Long {
return duration
}
/**
* 当前播放进度*/
fun position(): Long {
return curPosition
}
/**
* 改变进度条*/
fun seek(pos: Long) {
player.seekTo(pos, SEEK_CLOSEST)
}
private fun getSongUrl(id: String, callback: IGenerallyInfo) {
var url = HttpUtil.getSongURL(id)
HttpUtil.getGenerallyInfo(url, object : IGenerallyInfo {
override fun onRespond(json: String?) {
val array = JSONObject(json.toString()).getJSONArray("data")
val songURL = JSONObject(array[0].toString()).getString("url")
callback.onRespond(songURL)
}
override fun onFailed(e: String?) {
callback.onFailed(e.toString())
}
})
}
interface ProgressStatus {
fun getCurrentProgress(isStart: Boolean, current: Long, duration: Long)
}
fun setProgressListener(listener: ProgressStatus) {
this.listener = listener
}
}
歌单&专辑
效果
代码
由于歌单和专辑的接口存在差异,就分为了两个界面,但内容大同小异。操作也很简单,请求接口,将所有内容更新到UI就行,在对每一个的item进行点击事件处理,可以进行歌曲播放
点击播放全部按钮,将当前歌单内所有歌曲添加到播放列表
/**
* 点击播放按钮,将当前歌单内所有歌曲添加到播放列表*/
private fun addToSongList() {
/**
* 获取数据库实例*/
val db = PlayListDataBase.getDBInstance().playListDao()
val allSongs = db.getAll()
if (allSongs != null || allSongs.size > 0) {
db.deleteAll()
}
db.insertAll(songBeanList)
ToastUtil.setSuccessToast(this@SongListActivity, "已添加${songBeanList.size}首歌至播放列表")
}
高斯模糊和圆角处理,值得注意的是需要判断当前activity是否正在运行,因为当点击进入某个歌单,开始网络请求,但你忽然点击退出,此时activity已经销毁,但glide拿到返回的结果还会继续加载,但因为没有上下文,会出现异常,故而加一个判断。默认采用歌单第一首歌曲的cover作为歌单的cover
if (!this.isFinishing) {
Glide.with(binding.songListCover)
.asDrawable()
.load(songBeanList[0].albumCover)
.transform(GlideRoundTransform(BaseApplication.context, 10))
.placeholder(R.drawable.icon_default_songs)
.dontAnimate()
.into(binding.songListCover)
Glide.with(binding.albumCover)
.asDrawable()
.load(songBeanList[0].albumCover)
.apply(bitmapTransform(BlurTransformation(50)))
.placeholder(R.drawable.icon_default_songs)
.dontAnimate()
.into(binding.albumCover)
}
最近播放
效果
代码
UI
TabLayout+ViewPager+Fragment
UI布局如上所述,较为简单,通过请求一个相同接口,根据页面内容传入不同的type
获取不同的内容。每一个fragment
页面加一个loding,并创造一个父fragment
,在里面设计懒加载
,并让所有fragment继承此父类;意味,只有当用户滑到此页面时,才开始执行父fragment
暴露出来抽象函数中的内容,可放置一个网络和io操作,因为并不是所有的界面用户都会去看,所以避免内存消耗,采用此方法进行解决。
以歌曲为例,当此界面可见时,加载initLazyFunc
的内容,然后loading
展现,并请求网络
override fun initLazyFunc() {
binding.loading.show()
getRecentInfo()
}
private fun getRecentInfo(){
val url = HttpUtil.getRecentlyURL(MusicParam.recentlySong)
HttpUtil.getGenerallyInfo(url,object : IGenerallyInfo{
override fun onRespond(json: String?) {
Log.d(TAG, json.toString())
val results = JSONObject(json.toString()).getJSONObject("data").getJSONArray("list")
val songs = HttpUtil.fromListJson(results.toString(), SongRecentBean::class.java)
val message = Message()
message.what = UPDATE
message.obj = songs
handler.sendMessage(message)
}
override fun onFailed(e: String?) {
Log.d(TAG,e.toString())
}
})
}
搜索
效果
代码
搜索匹配列表UI
SearchView+RecyclerView
下面分别为历史搜索记录、歌单、歌曲、MV推荐信息。
通过监听SearchView
的输入字符,然后并将每一个不为空的字符进行网络请求,在将相关数据进行列表展现。
搜索历史记录使用Room
数据库,为了不占太大空间,最多可显示9个记录,并且每一次插入都进行一次更新。若存在相同项,则删除原项,并插入新的关键字,然后倒置数列,最后取后面9个。
/**
* 历史搜索记录最大显示个数为9个,故采用九宫格形式
* 从最后添加的开始追加
* 若存在相同项,则删除原项,将新项添加到第一个显示*/
private fun updateHistory(key: String) {
val dao = PlayListDataBase.getDBInstance().historyDao()
var all = dao.getAll()
if (!TextUtils.isEmpty(key)){
val bean: HistorySearchBean = dao.findByKey(key)
if (bean != null){
dao.delete(bean)
Log.d(TAG,"搜索记录=${bean.key}")
}
dao.insert(HistorySearchBean(key))
all = dao.getAll()
}
all.reverse()
historyList.clear()
if (all.size < 9) {
historyList.addAll(all)
} else {
var count = 0
for (item in all) {
count++
if (count > 9) break
historyList.add(item)
}
}
historyAdapter.notifyDataSetChanged()
}
搜索结果页
效果
代码
搜索结果页和最近播放一样,分为四个部分:(歌曲、专辑、歌单、MV),实现原理也和最近播放一样,不同于的地方在于,搜索结果页顶部多了一个搜索栏,每一次的搜索点击确定之后,通过EventBus
分发给四个Fragment
,然后在重新获取数据,进行更新替换。
@Subscribe(threadMode = ThreadMode.MAIN,sticky = true)
fun onEvent(bean: SearchKeyBean){
binding.loading.show()
getSongsResult(bean.key)
}
/**
* 获取歌曲搜索结果*/
private fun getSongsResult(keyWords: String){
val url = HttpUtil.getSearchResultURL(keyWords,1,30)
HttpUtil.getGenerallyInfo(url, object : IGenerallyInfo {
override fun onRespond(json: String?) {
Log.d(TAG, json.toString())
val results = JSONObject(json.toString()).getJSONObject("result").getJSONArray("songs")
val songs = HttpUtil.fromListJson(results.toString(), ResultBean::class.java)
val message = Message()
message.what = UPDATE
message.obj = songs
handler.sendMessage(message)
}
override fun onFailed(e: String?) {
Log.d(TAG, e.toString())
}
})
}
MV
效果
代码
视频播放器采用第三方开源播放器GSYVideoPlayer
,通过重写播放器,改变播放器滑动进度条样式、声音滑动进度条样式、亮度进度条样式,加载loading样式等。横竖屏切换会导致activity
重建,所以需要在清单文件中对该Activity进行configChanges
配置,然后每一次旋转屏幕就在onConfigurationChanged
回调中处理竖屏和横屏。
private fun initPlayer() {
orientationUtils = OrientationUtils(this, binding.iVideoPlayer)
val builder = GSYVideoOptionBuilder()
builder.setUrl(null)
.setVideoTitle(null) //movie title
.setIsTouchWiget(false) //小屏时不触摸滑动
.setRotateViewAuto(false) //是否开启自动旋转
.setLockLand(false) //一全屏就锁屏横屏,默认false竖屏,可配合setRotateViewAuto使用
.setAutoFullWithSize(true) //是否根据视频尺寸,自动选择竖屏全屏或者横屏全屏
.setShowFullAnimation(true) //全屏动画
.setNeedLockFull(false) //是否需要全屏锁定屏幕功能
.setCacheWithPlay(true) //是否边缓存,m3u8等无效
.setReleaseWhenLossAudio(false) //音频焦点冲突时是否释放
.setSeekRatio(8f)
.setSeekOnStart(0)
.setIsTouchWigetFull(true) //是否可以全屏滑动界面改变进度,声音等
.setVideoAllCallBack(object : GSYSampleCallBack() {
override fun onPrepared(url: String, vararg objects: Any) {
super.onPrepared(url, *objects)
orientationUtils.isEnable = true
}
override fun onQuitFullscreen(url: String, vararg objects: Any) {
super.onQuitFullscreen(url, *objects)
orientationUtils.backToProtVideo()
}
})
binding.iVideoPlayer.backButton.setOnClickListener(View.OnClickListener {
if (orientationUtils != null && GSYVideoManager.isFullState(this)) {
orientationUtils.backToProtVideo()
GSYVideoManager.backFromWindowFull(this)
} else {
finish()
}
})
}
榜单
效果
代码
榜单分为官方榜和精选榜两个榜单,顶部一个banner
,实现较为简单,略...
/**
* 获取排行榜信息*/
private fun getRankInfo(){
val url = HttpUtil.getTopRankURL()
HttpUtil.getGenerallyInfo(url,object : IGenerallyInfo{
override fun onRespond(json: String?) {
Log.d(TAG,json.toString())
val array = JSONObject(json.toString()).getJSONArray("list")
val result = HttpUtil.fromListJson(array.toString(),SheetPlayBean::class.java)
val message = Message()
message.what = UPDATE_RANK
message.obj = result
handler.sendMessage(message)
}
override fun onFailed(e: String?) {
Log.d(TAG,e.toString())
}
})
}
private fun updateRank(rankList: List<SheetPlayBean>){
for (rank in rankList){
if (rank.tracks.size() == 0){
//精选榜
selectBeanList.add(rank)
}else{
//官网榜
officialBeanList.add(rank)
}
}
officialAdapter.notifyDataSetChanged()
selectAdapter.notifyDataSetChanged()
binding.loading.hide()
}
登录
效果
代码
如上图所示,登录分为三种方式:账号密码、手机验证码、二维码;登陆成功会获得一个cookie
,然后将此cookie
保存到本地,下一次启动app时,在Application
进行判断,如果cookie存在则跳过登录,反之进行登录,如上述表示而言,此APP与网易云音乐账号相关联,所有必须使用网易云音乐账号。
val mCookie: String = SPUtil.getInstance().GetData(context, CacheKeyParam.cookieKey,"") as String
val mId: String = SPUtil.getInstance().GetData(context, CacheKeyParam.UserId,"") as String
val intent: Intent = if (TextUtils.isEmpty(mCookie)){
userId = ""
Intent(this,LoginActivity::class.java)
}else{
cookie = mCookie
userId = if (TextUtils.isEmpty(mId))
""
else
mId
Intent(this,HomePageActivity::class.java)
}
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK;
startActivity(intent)
尾言
此项目仅用于学习所用,仍存在许多不足,如在使用过程中出现Bug,可提交反馈,但请尽量描述清楚复现过程
- News53231323@163.com
- 2337466033
Git链接
如果喜欢可以点个star
Gitte项目链接