Android——一个简单的音乐APP

简介: Github上有位牛人将网易云音乐的接口进行部署和总结,然后我将它的仓库部署到我的云服务器上,因为他的是需要翻墙的,此项目所有接口信息均与网易云音乐关联。由于此项目用于熟悉kotlin语言,所以绝大部分代码均使用kotlin编写;目前EasyMusic为第一版,功能上还有欠缺,但主流程已经基本完成;

效果视频

效果视频地址

简介

Github上有位牛人将网易云音乐的接口进行部署和总结,然后我将它的仓库部署到我的云服务器上,因为他的是需要翻墙的,此项目所有接口信息均与网易云音乐关联。
由于此项目用于熟悉kotlin语言,所以绝大部分代码均使用kotlin编写;
目前EasyMusic为第一版,功能上还有欠缺,但主流程已经基本完成;

技术栈

  • 网络:Okhttp+GSON
  • 存储:Room数据库+SharedPreferences
  • UI:Material Design,ViewBinding
  • 其余:EventBus,Glide...

实现功能

  • 最近播放(歌曲,歌单,专辑,mv)
  • 播放列表
  • 歌曲播放(折叠播放、播放详情页)
  • 歌词
  • 搜索(历史搜索记录、搜索匹配列表、歌曲,歌单,专辑,mv)
  • 榜单(官方榜,精选榜)
  • 推荐(歌曲,歌单,mv)
  • 登录(账号密码、手机验证码、二维码)

具体实现

首页

效果

4043c1939f684cc8b38127e8a5548571.jpeg
9c320b72097a42d2b6700988c0275a51.jpeg

代码

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

播放

效果

fd90c133da0548759f0e18fb155a0d7c.jpeg
aefb881eaf734d4aa1e04b2b8d89e45f.jpeg

代码

播放的开始、暂停、上一首、下一首、进度回调等都封装在一个单例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
    }
}

歌单&专辑

效果

d0d017e971c84255a263c0ddb35858b3.jpeg

代码

由于歌单和专辑的接口存在差异,就分为了两个界面,但内容大同小异。操作也很简单,请求接口,将所有内容更新到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)
        }

最近播放

效果

61ef55338e96459bbba89be6ce03a407.jpeg
542eb92f5c0b48bba5809ddc63836589.jpeg
91490d4034f943b68cd3e8139ca8d5de.jpeg

代码

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())
            }
        })
    }

搜索

效果

83a331036b86489fa5089d546918f406.jpeg
db8a1247c5994edf9570be93e8428157.jpeg

代码

搜索匹配列表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()
    }

搜索结果页

效果

9c70e6f3dcb74b5cad22a886f749a6db.jpeg
c50b894a5fc04681a4ea195137ddf7da.jpeg
0c9496fc870f4348ac400eb14db0485f.jpeg

代码

搜索结果页和最近播放一样,分为四个部分:(歌曲、专辑、歌单、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

效果

843f08701f33492f920ea18c3e3e38c2.jpeg
c35e253ca3ef4613bf82f2604f1e7060.jpeg

代码

视频播放器采用第三方开源播放器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()
            }
        })
    }

榜单

效果

ab04a904ff9b426892a086d4b352ff92.jpeg

代码

榜单分为官方榜和精选榜两个榜单,顶部一个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()
    }

登录

效果

475945c7ded7421fb7602889ca8e39b3.jpeg
289064420ced49639e0b751c8e93d534.jpeg
562d4131e103473788dccfb183216c1b.jpeg

代码

如上图所示,登录分为三种方式:账号密码、手机验证码、二维码;登陆成功会获得一个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,可提交反馈,但请尽量描述清楚复现过程

  1. News53231323@163.com
  2. 2337466033

Git链接

如果喜欢可以点个star
Gitte项目链接

相关文章
|
17天前
|
存储 Java API
Android 浅度解析:mk预置AAR、SO文件、APP包和签名
Android 浅度解析:mk预置AAR、SO文件、APP包和签名
72 0
|
2天前
|
Android开发
Android APP 隐藏系统软键盘的方法
Android APP 隐藏系统软键盘的方法
10 0
|
2天前
|
Android开发
Android修改默认system/bin/下可执行程序拥有者和权限,使用实例,只有root和系统app权限才能执行某个命令。
Android修改默认system/bin/下可执行程序拥有者和权限,使用实例,只有root和系统app权限才能执行某个命令。
11 0
|
16天前
|
XML Java Android开发
Android系统 添加动态控制屏幕方向、强制APP横竖屏方向
Android系统 添加动态控制屏幕方向、强制APP横竖屏方向
32 1
|
17天前
|
测试技术 Android开发
Android App获取不到pkgInfo信息问题原因
Android App获取不到pkgInfo信息问题原因
19 0
|
22天前
|
Android开发 UED 开发者
解释Android App Bundle是什么,它的优势是什么?
Android App Bundle是Google开发的优化应用分发技术,它打包应用及资源以减少下载大小,加快加载速度,节省用户流量。App Bundle支持离线使用,简化更新过程,提升用户体验。开发人员借此能更高效地构建和分发Android应用。
13 0
|
2月前
|
Android开发
Unable to resolve dependency for ':app@debug/compileClasspath': Could not resolve com.android.suppor
Unable to resolve dependency for ':app@debug/compileClasspath': Could not resolve com.android.suppor
15 1
|
2月前
|
设计模式 测试技术 数据库
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
|
1月前
|
移动开发 小程序
如何让uni-app开发的H5页面顶部原生标题和小程序的顶部标题不一致?
如何让uni-app开发的H5页面顶部原生标题和小程序的顶部标题不一致?
|
2月前
|
API 数据安全/隐私保护 iOS开发
利用uni-app 开发的iOS app 发布到App Store全流程
利用uni-app 开发的iOS app 发布到App Store全流程
103 3