效果视频
前言
一个简单的音乐APP(第一篇)
第二版基于第一版新增了以下功能:
- 音乐下载
- 音乐离线播放
- mLog视频播放
- 个人信息
- 音乐信息
- 删除本地音乐
基于第一版也稍稍有些变动,废弃了二维码登录功能(依旧能登录,但有少部分功能无法联动使用),部分代码结构有变动
音乐下载
音乐下载效果图
实习步骤&思想
- 将所有选中的下载项与数据库中的子项进行匹配,如果存在相同的则不添加到下载队列中
- 下载队列采用先入先出,如果某一个音乐获取的音乐源为空,则代表需要会员或者需要购买才能获取下载链接(因为我的账号为非会员,故需要会员下载的歌曲无法进行下载,有会员的账户可以正常获取下载链接);如果为获取链接为空,则从下载队列之中删除此项,以及删除数据库对应项、本地项
- 正常获取的音乐下载链接,则通过
FileDownloader
开源库进行下载,然后通过EventBus
跨模块进行下载进度实时更新 - 断点续传:如果有未下载完成的下载项,被强制退出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
进行下载进度跨模块更新,其中需要关注的只有progress
和completed
两个回调,前者需要不断进行下载进度回调,后面在下载完成之后需要改变下载项,并且更新数据库相对应实例,方便下载完成显示界面正常;而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视频播放效果图
样式
原理与MV视频播放一致,只是界面发生些许变化;因为重写了GSYVideoPlayer
的样式,此处就介绍一下样式。
进度、声音、亮度
声音
亮度、声音、进度类似,就以声音为例;下列代码只是显示了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);
}
}
个人信息
个人信息效果图
行政代码解析
个人信息界面就是普通的数据展示,此处讲解一下地区,因为接口返回的是省和市的行政代码,然后找了一个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
}
}