Android——一个简单的音乐APP(二)

简介: 第二版基于第一版新增了以下功能: 1. 音乐下载 2. 音乐离线播放 3. mLog视频播放 4. 个人信息 5. 音乐信息 6. 删除本地音乐

效果视频

效果视频地址

前言

一个简单的音乐APP(第一篇)
第二版基于第一版新增了以下功能:

  1. 音乐下载
  2. 音乐离线播放
  3. mLog视频播放
  4. 个人信息
  5. 音乐信息
  6. 删除本地音乐

基于第一版也稍稍有些变动,废弃了二维码登录功能(依旧能登录,但有少部分功能无法联动使用),部分代码结构有变动

音乐下载

音乐下载效果图

e7f7e44319a04f128ac5173f2a97b85d.jpeg
3fb7f37e800d494489a0f52ae0e79af2.png
318587d1b95946d1be8478db2a5ad02b.jpeg

实习步骤&思想

  1. 将所有选中的下载项与数据库中的子项进行匹配,如果存在相同的则不添加到下载队列中
  2. 下载队列采用先入先出,如果某一个音乐获取的音乐源为空,则代表需要会员或者需要购买才能获取下载链接(因为我的账号为非会员,故需要会员下载的歌曲无法进行下载,有会员的账户可以正常获取下载链接);如果为获取链接为空,则从下载队列之中删除此项,以及删除数据库对应项、本地项
  3. 正常获取的音乐下载链接,则通过FileDownloader开源库进行下载,然后通过EventBus跨模块进行下载进度实时更新
  4. 断点续传:如果有未下载完成的下载项,被强制退出APP,当下次重新进去app时,自动断电续传,完成下载

添加到下载队列

下载选中以及下载弹窗提醒就跳过,先讲解添加到下载队列;先取出数据库实例,如果存在相同项则不添加到下载队列中,反之添加;此数据库以歌曲id歌曲名称作为主键,防止重复

private fun isExistIdentity(bean: SongBean): Boolean{
        val dao = PlayListDataBase.getDBInstance().downloadDao()
        val key = "${bean.songId}${bean.songName}"
        val isExist: DownloadBean = dao.findByKey(key)
        /**
         * 如果不为空,则代表存在,则不再进行重复下载*/
        if (isExist != null) return true

        val downloadBean = DownloadBean(key,bean.songName,bean.songId,bean.singerName,"",bean.albumCover,"","",0,0,false,false,"")

        /*update room*/
        dao.insert(downloadBean)

        /*update binder*/
        DownloadBinder.downloadList.add(downloadBean)
        return false
    }

单任务下载

          val flag = isExistIdentity(bean)
                if (flag){
                    ToastUtil.setFailToast(this@SongListActivity,"已存在相同下载项,请勿重复添加!")
                }else{
                    DownloadBinder.download()
                    ToastUtil.setSuccessToast(this@SongListActivity,"已添加到下载队列中!")
                }

多任务下载

          var count = 0
            for (i in songBeanList.indices){
                if (songBeanList[i].isSelect){
                    var flag = isExistIdentity(songBeanList[i])
                    if (flag)count++
                }
            }
            if (count> 0)ToastUtil.setFailToast(this@SongListActivity,"部分音乐可能已经存在下载队列之后,请勿重复添加!")
            DownloadBinder.download()
            downloadPop.dismiss()
        }

音乐下载

下载class为一个Service服务的单例Binder实现类

获取音乐下载源

通过音乐id获取其下载链接,然后回调给调用放,如上述表示所言,非会员账号,下载部分歌曲无法获取音乐下载源,故需要进行字符判断

 /**
     * 获取音乐下载源*/
    private fun startDownload(bean: DownloadBean,callback: IGenerallyInfo) {
        val url = HttpUtil.getDownloadURL(bean.songId.toString())

        HttpUtil.getGenerallyInfo(url, object : IGenerallyInfo {
            override fun onRespond(json: String?) {
                val downloadUrl = JSONObject(json.toString()).getJSONObject("data").getString("url").toString()
                if (!TextUtils.isEmpty(downloadUrl) && downloadUrl != "null") {
                    callback.onRespond(downloadUrl)
                } else {
                    callback.onFailed("此音乐需要开通才能进行下载!")
                }
            }

            override fun onFailed(e: String?) {
                callback.onFailed(e.toString())
            }
        })
    }

创建本地路径

对下载队列进行大小判断,防止下标越界异常,然后通过创建本地文件,对正常下载源进行下载,非正常音乐源进行删除(下载队列删除、数据库删除)

fun download() {
        if (downloadList.size == 0) return

        if (current >= downloadList.size) {
            ToastUtil.setSuccessToast(HomePageActivity.MA, "下载完成!")
            current = 0
            downloadList.clear()
            return
        }

        val name = downloadList[current].songId.toString() + downloadList[current].songName + ".mp3"
        val path = locationDir + File.separator + name
        downloadList[current].path = path

        startDownload(downloadList[current],object :IGenerallyInfo{
            override fun onRespond(json: String?) {
                downloadList[current].url = json.toString()
                bindDownload(path, downloadList[current].url)
            }

            override fun onFailed(e: String?) {
                delete(downloadList[current])
                EventBus.getDefault().postSticky(DownloadingBean(111, "", percentage, e.toString(), null))
                download()
            }
        })
    }
创建目录
public String mainCatalogue() {
        String path = "EasyMusicDownload";
        File dir = new File(BaseApplication.getContext().getCacheDir(), path);
        File movie = BaseApplication.getContext().getExternalFilesDir(Environment.DIRECTORY_MUSIC);
        if (movie != null) {
            dir = new File(movie, path);
        }
        if (!dir.exists()) {
            dir.mkdirs();
        }
        return dir.toString();
    }

开始音乐下载

在不同的回调中进行不同的处理,通过EventBus进行下载进度跨模块更新,其中需要关注的只有progresscompleted两个回调,前者需要不断进行下载进度回调,后面在下载完成之后需要改变下载项,并且更新数据库相对应实例,方便下载完成显示界面正常;而warn回调则无需进行太多关注,因为我们添加到下载队列之前已经判断过是否存在重复项啦,在代码正常情况下,无需担心

private fun bindDownload(path:String,url:String){
        FileDownloader.getImpl()
            .create(url)
            .setPath(path, false)
            .setListener(object : FileDownloadListener() {
                override fun pending(task: BaseDownloadTask, soFarBytes: Int, totalBytes: Int) {
                    //已经进入下载队列,正在等待下载
                    EventBus.getDefault().postSticky(DownloadingBean(task.status, "", 0, "", null))
                }

                override fun started(task: BaseDownloadTask?) {
                    //结束了pending,并且开始当前任务的Runnable
                    if (task != null) {
                        EventBus.getDefault().postSticky(DownloadingBean(task.status, "", 0, "", null))
                    }
                }

                override fun connected(task: BaseDownloadTask?, etag: String?, isContinue: Boolean, soFarBytes: Int, totalBytes: Int) {
                    //已经连接上
                }

                override fun progress(task: BaseDownloadTask, soFarBytes: Int, totalBytes: Int) {
                    //soFarBytes:已下载字节数
                    //totalBytes:文件总字节数
                    Log.d("downloadTAG", "running:${task.speed}")
                    percentage = ((soFarBytes * 1.0 / totalBytes) * 100).toInt()
                    EventBus.getDefault().postSticky(DownloadingBean(task.status, "${remainDigit(task.speed / 8.0)}KB/s", percentage, "", downloadList[current]))
                }

                override fun completed(task: BaseDownloadTask) {
                    //status = -3
//                /**
//                 * 除2个1024的到大小MB
//                 * 记得最后保留一位小数*/
                    val primary = "${downloadList[current].songId}${downloadList[current].songName}"
//
//                /**
//                 * 下载完成之后,更新数据库字段*/
                    PlayListDataBase.getDBInstance().downloadDao().updateComplete(primary, true)
                    PlayListDataBase.getDBInstance().downloadDao().updatePath(primary, task.path)
                    PlayListDataBase.getDBInstance().downloadDao().updateUrl(primary, task.url)
                    val size = remainDigit(task.smallFileTotalBytes * 1.0 / 1024 / 1024)
                    PlayListDataBase.getDBInstance().downloadDao().updateSize(primary, "${size}MB")
                    EventBus.getDefault().postSticky(DownloadingBean(task.status, "", percentage, "", downloadList[current]))

                    current++
                    download()
                }

                override fun paused(task: BaseDownloadTask, soFarBytes: Int, totalBytes: Int) {
                    //callback.pause(task)
                    Log.d("downloadTAG", "pause:${task.status}")
                    EventBus.getDefault().postSticky(DownloadingBean(task.status, "", percentage, "", downloadList[current]))
                }

                override fun error(task: BaseDownloadTask, e: Throwable) {
                    // callback.failed(task,e.message)
                    //error = -1
                    Log.d("downloadTAG", "failed:${task.status}")
                    Log.d("downloadTAG", "failed:${e.message}")
                    EventBus.getDefault().postSticky(DownloadingBean(task.status, "", percentage, e.message!!, downloadList[current]))

                    download()
                }

                /**
                 * 存在相同项目*/
                override fun warn(task: BaseDownloadTask) {
                    // callback.exist(task)
                    /**
                     * 不会进入此处
                     * 因为外面已经判断过重复项*/
                    Log.d("downloadTAG", "exist:${task.status}")
                    EventBus.getDefault().postSticky(DownloadingBean(111, "", percentage, "", downloadList[current]))

                    //current++
                    download()
                }
            }).start()
    }

下载进度回调

 /**
     * 下载回调监听*/
    @Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
    fun onEvent(bean: DownloadingBean){
        update(bean)
    }
下载进度更新

下载进度总共有四种状态:默认状态(灰色)、绿色(当前下载项)、黄色(下载暂停)、红色(下载异常);为了提醒四种状态,就没有贴代码,而是放了图片,因为旁边有颜色提示,更加明了
在这里插入图片描述
对应上面的状态进行不同的处理

    /**
     * currentItem等于-1代表没有相同项
     * currentItem等于-2代表状态改变,比如下载完成,初始化-2*/
    private fun update(bean: DownloadingBean){
        when(bean.status){
            pending_start->{}
            running->{
                updateItem(bean,1)
            }
            completed->{
                if (currentItem != -1 && currentItem != -2){
                    adapter.deleteCompletedItem(currentItem)
                    currentItem = -2
                    EventBus.getDefault().postSticky(DownloadCompleteBean(true))
                }
            }
            failed->{updateItem(bean,3)}
            error->{
                ToastUtil.setFailToast(HomePageActivity.MA,bean.error)
            }
        }
    }
寻找当前下载项

-2(默认)下载完成,需要重新寻找下载项,-1代表下载队列已经全部下载完成,已经没有匹配项;反之,为当前下载项,进行进度更新

    private fun updateItem(bean: DownloadingBean,status: Int){
        when (currentItem) {
            -2 -> {
                searchItem(bean)
            }
            -1 -> {
                //没有相同项
            }
            else -> {
                downloadList[currentItem].progress = bean.percentage
                downloadList[currentItem].speed = bean.speed
                adapter.notifyItemChanged(currentItem,status)
            }
        }
    }

离线播放

离线播放较为简单,因为之前在线播放使用的都是同一个组件,在线播放传入音乐url,而离线播放传入本地音乐的绝对地址即可,故而没有任何变化。

mLog视频播放

mLog视频播放效果图

f2e5f1fe0811400b8516c2a38ba4018c.jpeg#pic_center

样式

原理与MV视频播放一致,只是界面发生些许变化;因为重写了GSYVideoPlayer的样式,此处就介绍一下样式。

进度、声音、亮度

8f106ea7732d4b61912d0eb37aa76885.jpeg
0a9904694e584e9790376c6b2dfb01c3.jpeg
56a154a3a3bc4c849631f64ace8d869c.jpeg

声音

2de266a7ea064a919c996e43cc9f9791.png#pic_center
亮度、声音、进度类似,就以声音为例;下列代码只是显示了Dialog以及相关属性,具体通过滑动屏幕计算音量大小,然后改变系统音量大小由下列代码实现(部分代码)

           deltaY = -deltaY;
            int max = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
            int deltaV = (int) (max * deltaY * 3 / curHeight);
            mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, mGestureDownVolume + deltaV, 0);
            int volumePercent = (int) (mGestureDownVolume * 100 / max + deltaY * 3 * 100 / curHeight);
            showVolumeDialog(-deltaY, volumePercent);
@Override
    protected void showVolumeDialog(float deltaY, int volumePercent) {
        WindowManager.LayoutParams localLayoutParams = null;
        if (mVolumeDialog == null) {
            View localView = LayoutInflater.from(getActivityContext()).inflate(getVolumeLayoutId(), null);
            if (localView.findViewById(getVolumeProgressId()) instanceof ProgressBar) {
                mDialogVolumeProgressBar = ((ProgressBar) localView.findViewById(getVolumeProgressId()));
                if (mVolumeProgressDrawable != null && mDialogVolumeProgressBar != null) {
                    mDialogVolumeProgressBar.setProgressDrawable(mVolumeProgressDrawable);
                }
            }
            mVolumeDialog = new Dialog(getActivityContext(), R.style.video_style_dialog_progress);
            //mVolumeDialog = new Dialog(getActivityContext());
            mVolumeDialog.setContentView(localView);
            mVolumeDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
            mVolumeDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
            mVolumeDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
            //mVolumeDialog.getWindow().setLayout(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
            mVolumeDialog.getWindow().setLayout(380, 50);
            localLayoutParams = mVolumeDialog.getWindow().getAttributes();
            localLayoutParams.gravity = Gravity.CENTER;
            localLayoutParams.width = getWidth();
            localLayoutParams.height = getHeight();
        }
        if (!mVolumeDialog.isShowing()) {
            mVolumeDialog.show();
            mVolumeDialog.getWindow().setAttributes(localLayoutParams);
        }
        if (mDialogVolumeProgressBar != null) {
            mDialogVolumeProgressBar.setProgress(volumePercent);
        }
    }

个人信息

个人信息效果图

63aa174e233e4860b2340d47f36bbe60.jpeg#pic_center

行政代码解析

个人信息界面就是普通的数据展示,此处讲解一下地区,因为接口返回的是省和市的行政代码,然后找了一个XML 全国省市区+区行政代码的文件,通过PULL方式进行解析,然后展示,此处讲解这个

XML数据格式

以安徽省-安庆市为例,此文件没有Text内容,所有信息全在标头上

<province name="安徽省" zipcode="340000">
    <city name="安庆市" zipcode="340800">
      <district name="枞阳县" zipcode="340800" />
      <district name="大观区" zipcode="340800" />
      <district name="怀宁县" zipcode="340800" />
      <district name="潜山县" zipcode="340800" />
      <district name="宿松县" zipcode="340800" />
      <district name="太湖县" zipcode="340800" />
      <district name="桐城市" zipcode="340800" />
      <district name="望江县" zipcode="340800" />
      <district name="宜秀区" zipcode="340800" />
      <district name="迎江区" zipcode="340800" />
      <district name="岳西县" zipcode="340800" />
      <district name="其他" zipcode="340800" />
    </city>
    </province>
代码

这是为解析这个文件写的一个解析类,首先获取这个资源文件,这个文件可以存储在xml目录下、raw目录下、assets目录下都可以;前者与后两者有稍微不同,但解析步骤一样,只是获取资源文件方式不同。我这个采用的是一个嵌套类,

  • 省数据类中包括:省名称、省行政代码、城市集合
  • 城市数据类中包括:城市名称、城市行政代码、区县集合
  • 区县数据类中包括:区县名称、区县行政代码(区县在该文件中没有行政代码,所有代码均为该市行政代码)
object XMLParserUtil {
    private val provinceTag = "province"
    private val cityTag = "city"
    private val countyTag = "district"
    private val NAME = "name"
    private val CODE = "zipcode"
    private lateinit var pullParser : XmlPullParser
    private lateinit var provinceList:MutableList<ProvinceBean>

    init {
        provinceList = ArrayList<ProvinceBean>()
        create()
        resolve()
    }


    private fun create(){
        val factory = XmlPullParserFactory.newInstance();
        pullParser = factory.newPullParser();
        val inputStream = BaseApplication.context.resources.openRawResource(R.raw.region);
        pullParser.setInput(InputStreamReader(inputStream));
    }
    
    private fun resolve(){
        var province: ProvinceBean = ProvinceBean()
        var currencyProvince = false
        var currentCityFlag = false
        var currentCity: Int = -1
        var currentCounty: Int = -1
        try {
        var type = pullParser.eventType
        while (type != XmlPullParser.END_DOCUMENT){
            when(type){
                XmlPullParser.START_DOCUMENT-> provinceList = ArrayList<ProvinceBean>()

                XmlPullParser.START_TAG->{
                    when (pullParser.name) {
                        provinceTag -> {
                            /**
                             * 省份*/
                            province = ProvinceBean()
                            currencyProvince = true

                            province.provinceName = pullParser.getAttributeValue(null, NAME)
                            province.provinceCode = pullParser.getAttributeValue(null, CODE).toInt()
                        }
                        cityTag -> {
                            /**
                             * 城市*/
                            if (currencyProvince){
                                currentCity = -1
                                currencyProvince = false
                                province.cityList = ArrayList<CityBean>()
                            }
                            currentCityFlag = true
                            currentCity++
                            val bean = CityBean()
                            bean.cityName =  pullParser.getAttributeValue(null, NAME)
                            bean.cityCode = pullParser.getAttributeValue(null, CODE).toInt()
                            province.cityList.add(bean)
                        }
                        countyTag -> {
                            /**
                             * 区县*/
                            if (currentCityFlag){
                                currentCounty = -1
                                currentCityFlag = false
                                province.cityList[currentCity].countyList = ArrayList<CountyBean>()
                            }
                            currentCounty++
                            val bean = CountyBean()
                            bean.countyName = pullParser.getAttributeValue(null, NAME)
                            bean.countyCode = pullParser.getAttributeValue(null, CODE).toInt()
                            province.cityList[currentCity].countyList.add(bean)
                        }
                    }
                }

                XmlPullParser.END_TAG->{
                    val provinceName = pullParser.name
                    if (pullParser.name == provinceTag) provinceList.add(province)
                }
                XmlPullParser.END_DOCUMENT->{}
            }
            type = pullParser.next()
        }
        }catch (e: XmlPullParserException){
            e.printStackTrace()
        }catch (e:IOException){
            e.printStackTrace()
        }catch (e:NullPointerException){
            e.printStackTrace()
        }
    }

    /**
     * 根据省/城市名获取行政代码*/
    private fun getCodeByName(){

    }

    /**
     * 查询省级*/
    fun getProvinceNameByCode_B(code: Int):ProvinceBean?{
        val province = searchProvince(code)
        province?.let { return province }
        return null
    }

    fun getProvinceNameByCode_S(code: Int):String?{
        val province = searchProvince(code)
        province?.let { province.provinceName }
        return null
    }

    /**
     * 根据行政代码获取省/城市名
     * 模糊搜索:传入省或者城市编码,具体类型不明
     * 查找某个城市的名称,首先通过找到该省然后在进一步通过城市行政代码查询该市名称*/

    /**
     * 返回组合类型
     * 例如:湖南-长沙*/
    fun getCityNameByCode(provinceCode: Int,cityCode: Int,split: String): String{
        val builder = StringBuilder()
        val province = searchProvince(provinceCode)
        if (province != null){
            builder.append(province.provinceName).append(split)
            val city = searchCity(cityCode, province)
            city?.let {
                builder.append(city.cityName)
                return builder.toString()
            }
        }
        return ""
    }

    /**
     * 单独返回城市名称*/
    fun getCityNameByCode(province:ProvinceBean,cityCode: Int): String{
        val city = searchCity(cityCode, province)
        city?.let { return city.cityName }
        return ""
    }

    private fun searchProvince(code: Int): ProvinceBean?{
        if (provinceList.size == 0)return null
        for (i in 0 until provinceList.size){
            if (code == provinceList[i].provinceCode) return provinceList[i]
        }
        return null
    }

    private fun searchCity(code: Int,bean: ProvinceBean):CityBean?{
        if (bean.cityList.size == 0) return null
        for (i in 0 until bean.cityList.size){
            if (code == bean.cityList[i].cityCode) return bean.cityList[i]
        }
        return null
    }
}
相关文章
|
3月前
|
Android开发 开发者 iOS开发
APP开发后如何上架,上架Android应用市场前要准备什么
移动应用程序(APP)的开发已经成为现代企业和开发者的常见实践。然而,开发一个成功的APP只是第一步,将其上架到应用商店让用户下载和使用是实现其潜力的关键一步。
|
2天前
|
测试技术 Android开发
Android App获取不到pkgInfo信息问题原因
Android App获取不到pkgInfo信息问题原因
13 0
|
1月前
|
设计模式 测试技术 数据库
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
基于Android的食堂点餐APP的设计与实现(论文+源码)_kaic
|
2月前
|
安全 Java 数据挖掘
当 App 有了系统权限,真的可以为所欲为? Android Performance Systrace
当 App 有了系统权限,真的可以为所欲为? Android Performance Systrace 转载自: https://androidperformance.com/2023/05/14/bad-android-app-with-system-permissions/#/0-Dex-%E6%96%87%E4%BB%B6%E4%BF%A1%E6%81%AF
31 0
|
3月前
|
Android开发
闲暇时间收集和整理的Android的一些常用的App
闲暇时间收集和整理的Android的一些常用的App
14 0
|
3月前
|
Android开发 UED 开发者
解释Android App Bundle是什么,它的优势是什么?
解释Android App Bundle是什么,它的优势是什么?
58 0
|
3月前
|
JavaScript Android开发
Cordova 后台运行 Android APP
Cordova 后台运行 Android APP
|
4月前
|
物联网 Android开发
Android Ble蓝牙App(七)扫描过滤
Android Ble蓝牙App(七)扫描过滤
|
4月前
|
物联网 Android开发
Android Ble蓝牙App(六)请求MTU与显示设备信息
Android Ble蓝牙App(六)请求MTU与显示设备信息