接着上一次https://www.cnblogs.com/webor2006/p/15145953.html的功能继续往下学习,在上一次由于在网上找的数据接口挂了,重新又找了一个能用的接口https://neteasecloudmusicapi.vercel.app/#/,这里回忆一下具体用法,不然项目启动时看不到数据很受打击:
进入到官方的源码,然后启动既可:
此时要注意了,由于是运行在手机上,不是在电脑上,在APP的访问地址域名是不能用localhost的,需要改成本机的ip地址,也就是改它:
由于本机ip地址是会随着网络变化经常变的,所以在学习时一定要注意它的变化,及时进行更新调整。
接下来则处理MV列表的点击播放功能,预期的效果是:
这块都比较熟了,由于我们列表是使用的RecylerView来实现的,它不像ListView一样有现成的API可以监听列表的点击,需要给Item View进行事件监听,然后再自己定义接口回调到界面上,具体做法就是到Adapter中:
它里面的onBindViewHolder()中进行rootView的事件监听:
其中这里复习一下Kotlin的语法,为啥这里可以使用大括号?
关于这块可以参考https://www.cnblogs.com/webor2006/p/12622874.html之前的详细说明,好接下来则需要定义一个回调方法,这里用两种方式对比着学习。
这个就不过多解释了,先定义个接口,然后在里面进行调用既可:
很顺其自然对吧,但是对于Kotlin来说回调其实可以更加简便,所以下面来看一下Kotlin对于回调可以如何来定义?
在我们传统定义一个回调时其方法是必须定义在一个类当中的对吧?
但是在Kotlin中就不一样了,函数和类都是一等公民,函数可以独立存在的,所以咱们可以更加简便的来定义回调方法了,如下:
然后使用时如下:
还有另外一种调用方式:
其实熟悉java8也有类似的效果,也是将函数提升为一等公民了。
在继续往下编写之前,突然想到个东东,就是前几天看我的csdn的博客上有个网友在一篇Flutter的文章https://blog.csdn.net/webor2006/article/details/119747772中提了个问题:
其中Flutter也有空安全机制,跟Kotlin类似,这里针对这个问题借着这块代码也来稍加说明一下,其实也就是几种情况,一种是变量为空则加个?既可:
另外如果你在使用变量时,这样用可能会报错对吧:
此时,你必须按要求来处理,第一种是你认为该变量不可能为空,此时可以这样用:
但是此时要注意了,这是你自认为的,如果真的变量为空那么程序肯定就崩溃了,所以平常用它时一定要自己来确保空的问题,另外还有一种比较友好的写法就是我们目前所使用的:
再配合着let【关于let扩展函数的使用可以参考https://www.cnblogs.com/webor2006/p/13529865.html】,也就是如果listener为空,那么它里面的方法是不会执行的,这就保证了一个空安全判断的问题了,而对于Flutter的空安全几乎类似,可能语法有些些不同,度娘一下也很容易理解,这里就顺便回答一下该网友的提问了~~
这里运行看一下能否正常的监听到点击的条目:
接下来咱们可以处理一下界面的跳转,通常我们直接使用startActivity来进行跳转:
这里扩展个知识吧,其实可以用一个开源的库来简化调转代码,叫anko,地址为https://github.com/Kotlin/anko/issues,不过打开你会发现,官网已经提示该库已经被废弃了:
其实不影响使用,又可以当作自己一个知识面的扩展,假如你在某个项目中会碰到呢,所以这里还是用一下它,对于anko库它其实包含以下几个应用:
都是来帮我们简化平常的一些调用的,这里定位到Intents:
可能有人会说了,这么一个简单的代码也有必要使用三方库么?怎么说呢,三方库的产生都是有目的的,要不是为了性能,要不就是为了代码更加简洁方便提高咱们的开发效率,我觉得三方库不管你项目有没有用到,可以认识一下,反正现在是学习,多多扩展眼界总是好的,至于要不要用到你的项目中,这块就根据自己的意愿来了,好,既然要用它,则需要添加依赖到工程中,其实在之前https://www.cnblogs.com/webor2006/p/12612286.html已经添加进工程了:
下面直接用一下,你会发现用不了:
这是因为该库只对support的Fragment进行了方法扩展,对于Koltin来说要想达到一个封装通用的作法都是采用对系统类进行一个方法扩展,可以看一下这个startActivity的实现:
所以,结论就是还是采用传统的方式来进行Activity的跳转吧。。那,既然没用了你还写出来干嘛?一是扩宽自己的知识面,二是知道该库存在的问题,三是重新提一下它,因为它还是有使用场景的,比如目前toast的还是可以用的:
为啥,因为它是基于Context进行的系统扩展:
对于Kotlin来说扩展方法这个技巧一定要学会,在平常开发中你基于一些类的扩展可以大大提高开发效率。
所以下面代码还是用传统方式来进行跳转,很显然跳转是需要将当前点击的实体传过去的,但是对于咱们目前的item bean来说有很多在播放界面用不到的属性:
所以,为了传递的简洁,这里再封装一个新的Bean用来进行数据传递,如下:
package com.kotlin.musicplayer.model import android.os.Parcel import android.os.Parcelable /** * 传递给视频播放界面的bean类 */ data class VideoPlayBean(var id: Int, var title: String?, var url: String?) : Parcelable { constructor(parcel: Parcel) : this( parcel.readInt(), parcel.readString(), parcel.readString() ) override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeInt(id) parcel.writeString(title) parcel.writeString(url) } override fun describeContents(): Int { return 0 } companion object CREATOR : Parcelable.Creator<VideoPlayBean> { override fun createFromParcel(parcel: Parcel): VideoPlayBean { return VideoPlayBean(parcel) } override fun newArray(size: Int): Array<VideoPlayBean?> { return arrayOfNulls(size) } } }
然后跳转代码如下:
其中mv的地址写死了http://vfx.mtime.cn/Video/2019/02/04/mp4/190204084208765161.mp4,本身网上找的API数据不全,这里能正常播就成,不纠结是不是真实有效。
然后在播放界面就可以进行参数接收了,这里打印一下,看参数接收是否一切正常?
运行:
接下来就是处理视频播放了,通常也是基于一些三方的框架来傻瓜式的集成,世面上有多少视频播放的开源框架,目前我司项目中使用的是https://github.com/CarGuo/GSYVideoPlayer/这款,还是很火的,而这里学习采用另一款https://github.com/Jzvd/JZVideo,节操播入器,具体集成这里就不多说明了,直接按照官网来集成既可,下面将其集成到咱们工程中。
接下来运行看一下,发现报错了。。
e: /Users/xiongwei/.gradle/caches/transforms-2/files-2.1/978bdf9bc4b3844ec46f4a1babbe02fe/jetified-jiaozivideoplayer-7.7.0-api.jar!/META-INF/jiaozivideoplayer_release.kotlin_module: Module was compiled with an incompatible version of Kotlin. The binary version of its metadata is 1.5.1, expected version is 1.1.16.
而且在类上IDE有个提示:
网上搜了一下https://blog.csdn.net/qq_37875500/article/details/117418214,有说重启一下kotlin插件既可:
发现不好使。。于是在这贴子的评论处又找到一个新的解决方案:https://www.jianshu.com/p/3d4abaef8163,也就是更新一下Kotlin的版本,目前项目用的版本为:
改为:
嗯,貌似可以运行了,此时看一下效果:
此时点击播放时,发现APP崩溃了。。
又度娘一下https://blog.csdn.net/yao_zhuang/article/details/107095665,原来是没有指定jdk的版本为1.8,gradle中指定一下:
再运行看一下,不报错了,但是提示视频播放不了,是因为视频的地址有问题,这里在网上又换了一个地址:https://blog.csdn.net/qq_17497931/article/details/80824328,视频地址为:https://v-cdn.zjol.com.cn/280443.mp4,具体视频源这里可以自行找找,很有可能之后就不能用了,再运行:
对于一款视频播放器,肯定是要支持本地视频打开时可以用咱们的软件来进行播放对吧,效果如下:
关于这块的实现其实也不难,也就是对于Intent的进行一个处理,下面具体来实现一下。
<intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="http" /> <data android:scheme="https" /> <data android:mimeType="video/mp4" /> <data android:mimeType="video/3gp" /> <data android:mimeType="video/3gpp" /> <data android:mimeType="video/3gpp2" /> </intent-filter>
关于这个fitler可以网上找一下,此时咱们本地找一个视频,打开就可以在列表中出现咱们的应用了:
当然目前还打不开,因为还没有做相关数据的处理。
这里其实可以先来打印一下本地视频打开来自intent的视频地址:
2021-10-16 05:16:00.004 19194-19194/com.kotlin.musicplayer I/System.out: data=content://com.android.fileexplorer.myprovider/external_files/280443.mp4
而要访问sdcard内容,肯定需要加权限:
接下来处理播放逻辑:
发现播不了。。为啥呢?因为我手机是9.0的,而在Android7.0以后对于sdcard上的路径都是以content开头的,关于这块可以参考https://blog.csdn.net/divaid/article/details/79419858,对于我本地的视频路径应该是/storage/emulated/0/280443.mp4,而目前从intent中读取的是content://com.android.fileexplorer.myprovider/external_files/280443.mp4,很明显在7.0以上手机上需要进行处理一下,需要根据content的路径来查找真实的sdcard路径,而方法我是在网上搜到的https://blog.csdn.net/a1018875550/article/details/82957333?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_title~default-8.no_search_link&spm=1001.2101.3001.4242,这里将其封装成一个工具方法便于之后在其它界面也可以使用:
而对于工具方法一般都会将类设计成单例的对吧,在Kotlin中怎么来弄呢?代码如下:
package com.kotlin.musicplayer.utils import android.content.ContentResolver import android.content.Context import android.database.Cursor import android.net.Uri import android.provider.MediaStore import android.text.TextUtils import java.io.* object FileUtil { fun getFileFromUri(uri: Uri?, context: Context?): File? { return if (uri == null) { null } else when (uri.getScheme()) { "content" -> getFileFromContentUri(uri, context) "file" -> File(uri.getPath()) else -> null } } /** * Gets the corresponding path to a file from the given content:// URI * * @param contentUri The content:// URI to find the file path from * @param context Context * @return the file path as a string */ private fun getFileFromContentUri(contentUri: Uri?, context: Context?): File? { if (contentUri == null) { return null } var file: File? = null var filePath: String? = null val fileName: String val filePathColumn = arrayOf(MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME) val contentResolver: ContentResolver? = context?.getContentResolver() val cursor: Cursor? = contentResolver?.query( contentUri, filePathColumn, null, null, null ) if (cursor != null) { cursor.moveToFirst() try { filePath = cursor.getString(cursor.getColumnIndex(filePathColumn[0])) } catch (e: Exception) { } fileName = cursor.getString(cursor.getColumnIndex(filePathColumn[1])) cursor.close() if (!TextUtils.isEmpty(filePath)) { file = File(filePath) } if (!file!!.exists() || file.length() <= 0 || TextUtils.isEmpty(filePath)) { filePath = getPathFromInputStreamUri(context, contentUri, fileName) } if (!TextUtils.isEmpty(filePath)) { file = File(filePath) } } return file } /** * 用流拷贝文件一份到自己APP目录下 * * @param context * @param uri * @param fileName * @return */ fun getPathFromInputStreamUri(context: Context?, uri: Uri, fileName: String): String? { var inputStream: InputStream? = null var filePath: String? = null if (uri.authority != null) { try { inputStream = context?.contentResolver?.openInputStream(uri) val file = createTemporalFileFrom(context, inputStream, fileName) filePath = file!!.path } catch (e: java.lang.Exception) { } finally { try { if (inputStream != null) { inputStream.close() } } catch (e: java.lang.Exception) { } } } return filePath } @Throws(IOException::class) private fun createTemporalFileFrom( context: Context?, inputStream: InputStream?, fileName: String ): File? { var targetFile: File? = null if (inputStream != null) { var read: Int val buffer = ByteArray(8 * 1024) //自己定义拷贝文件路径 targetFile = File(context?.getCacheDir(), fileName) if (targetFile.exists()) { targetFile.delete() } val outputStream: OutputStream = FileOutputStream(targetFile) while (inputStream.read(buffer).also { read = it } != -1) { outputStream.write(buffer, 0, read) } outputStream.flush() try { outputStream.close() } catch (e: IOException) { e.printStackTrace() } } return targetFile } }
一个object声明就可以了,为啥?其实将它可以转换成java类就明白了:
然后再来修改一下咱们的视频处理代码:
此时就可以来运行看一下效果了,如果你是6.0以上手机,你会发现会报sdcard权限问题:
这是因为对于sdcard权限在6.0以后是需要我们主动申请才行的,这里为了方便,先手动进应用详情中打开它:
因为这块在之后会专门处理的,这里先略过,另外关于动态权限申请框架的搭建可以参考我之前写过的这篇https://www.cnblogs.com/webor2006/p/12757460.html,完整的记录了整个申请过程。好,接下来看一下最终效果:
对于应用外的视频应该咱们播放器还支持网络的对吧,所以接下来处理一下。
这里应该是在另一个app中来跳到咱们这款播放器app中对吧,这里以新建module的形式来准备这个测试跳转app:
然后搞个在线视频的测试入口,点击则跳转一下,具体如下:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" tools:context=".MainActivity"> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:onClick="onclick" android:text="打开网络视频" /> </RelativeLayout>
好,此时运行在手机上:
其处理也比较简单:
最后咱们运行看一下:
对于这个视频播放界面下面还空出一截对吧,接下来则需要来完善它,效果也很简单:
也就是一个tab滑动切换的效果,由于API目前网上也没找到比较合适的,这里就是占个位,具体内容这里就忽略了,其实就是一些视频的简介之类的,纯展示用的,比较简单,这里快速过一下。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <cn.jzvd.JzvdStd android:id="@+id/jz_video" android:layout_width="match_parent" android:layout_height="200dp" /> <RadioGroup android:id="@+id/rg" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="20dp" android:orientation="horizontal"> <RadioButton android:id="@+id/rb1" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@drawable/mv_description" android:button="@null" android:checked="true" /> <RadioButton android:id="@+id/rb2" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@drawable/mv_comment" android:button="@null" /> <RadioButton android:id="@+id/rb3" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@drawable/mv_relative" android:button="@null" /> </RadioGroup> <androidx.viewpager.widget.ViewPager android:id="@+id/viewPager" android:layout_width="match_parent" android:layout_height="match_parent" /> </LinearLayout>
其中RadioButton有三个背景资源,如下:
mv_comment.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@mipmap/player_comment_p" android:state_checked="true"/> <item android:drawable="@mipmap/player_comment"/> </selector>
mv_description.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@mipmap/player_mv_p" android:state_checked="true"/> <item android:drawable="@mipmap/player_mv"/> </selector>
mv_relative.xml:
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@mipmap/player_relative_mv_p" android:state_checked="true"/> <item android:drawable="@mipmap/player_relative_mv"/> </selector>
涉及到的图片如下:
player_comment.png:
player_comment_p.png:
player_mv.png:
player_mv_p.png:
player_relative_mv.png:
player_relative_mv_p.png:
由于就是占一个位,这里用一个Fragment既可,如下:
这块直接把代码贴出来了,都比较熟了,Kotlin语法也比较简单:
package com.kotlin.musicplayer.ui.activity import androidx.viewpager.widget.ViewPager import cn.jzvd.Jzvd import com.kotlin.musicplayer.R import com.kotlin.musicplayer.adapter.VideoPagerAdapter import com.kotlin.musicplayer.base.BaseActivity import com.kotlin.musicplayer.model.VideoPlayBean import com.kotlin.musicplayer.utils.FileUtil import kotlinx.android.synthetic.main.activity_video_player.* /** * 视频播放详情界面 */ class VideoPlayerActivity : BaseActivity() { override fun getLayoutId(): Int { return R.layout.activity_video_player } override fun initData() { super.initData() val data = intent.data println("data=$data") if (data == null) { //应用内视频处理 val videoPlayBean = intent.getParcelableExtra<VideoPlayBean>("item") jz_video.setUp( videoPlayBean.url, videoPlayBean.title ) } else { //应用外视频处理 if (data.toString().startsWith("http")) { //网络视频 jz_video.setUp( data.toString(), data.toString() ) } else { //本地视频 val filePath = FileUtil.getFileFromUri(data, this)?.absolutePath jz_video.setUp( filePath, filePath ) } } } override fun onBackPressed() { if (Jzvd.backPress()) { return } super.onBackPressed() } override fun onPause() { super.onPause() Jzvd.releaseAllVideos() } override fun initListeners() { //适配viewpager viewPager.adapter = VideoPagerAdapter(supportFragmentManager) //radiogroup选中监听 rg.setOnCheckedChangeListener { radioGroup, i -> when (i) { R.id.rb1 -> viewPager.setCurrentItem(0) R.id.rb2 -> viewPager.setCurrentItem(1) R.id.rb3 -> viewPager.setCurrentItem(2) } } //viewpager选中状态监听 viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { /** * 滑动状态改变的回调 */ override fun onPageScrollStateChanged(state: Int) { } /** * 滑动回调 */ override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int ) { } /** * 选中状态改变回调 */ override fun onPageSelected(position: Int) { when (position) { 0 -> rg.check(R.id.rb1) 1 -> rg.check(R.id.rb2) 2 -> rg.check(R.id.rb3) } } }) } }