Android Kotlin + MVVM:基于 LiveData 的段落列表音频播放与 AB 复读实现

本文分享一套在真实项目中沉淀的方案:Kotlin + MVVM + LiveData ,不依赖 EventBus,仅通过 observe 驱动整个播放链路。核心思路是维护两个播放指针------段落级(paragraphPos句子级(mPos ,并在 MediaPlayer 的播放完成回调中统一处理三种播放策略:自动连播、单段循环、AB 复读。 代码只能讲部分重点细节和设计思路。

在句式列表的播放中,列表由一句一句的音频组成,核心是直接使用音频监听的回调方法,在播放结束中处理各种业务逻辑。 其中ab复读是只先选择设置a句,再选择设置b句,设置完成之后再a句到b句之间反复播放。实用场景:比如课文中的重点段落,老师会要求学生背诵。 段落循环播放就比较简单了,播完继续播这一段就行了。 核心都是控制播放指针。

ini 复制代码
播放完成 → 判断模式
  ├─ isSingle=true  → 单段循环:如果当前句=段落尾句,跳回段首句;否则下一句
  ├─ isAbPlay=true  → AB复读:如果下一句超出B段尾,跳回A段首;否则正常推进
  └─ 其他            → 自动连播:如果超出全文末尾,回到开头;如果超出当前段尾,段落+1

ViewModel代码

ini 复制代码
   var isCollect = BooleanLiveData() //  是否收藏
    var speed = FloatLiveData() //  播放速度
    var readInf = StringLiveData() // 阅读建议
    var textId = StringLiveData() // 文本ID
    var readList = MutableLiveData<List<ReadTextBean>>() //  阅读列表
    var isSingle = BooleanLiveData() //  是否单段循环
    var isAbPlay = BooleanLiveData() //  是否ab复读
    var poiA = IntLiveData() //  A点位置 以段为下标
    var poiB = IntLiveData() //  B点位置 以段为下标

    var mPos = IntLiveData() // 当前播放位置 以句为下标
    var paragraphPos = IntLiveData() // 当前段落位置
    var showSpeedSelect = BooleanLiveData() //  是否显示播放速度选择
    var wordList = MutableLiveData<List<EnPaperWordBean.Word>>() //  单词详情列表
    var paragraphList = MutableLiveData<List<ParagraphBean>?>() //  段落列表

val mediaCallBack = object : MediaCommonUtil.Callback { //播放监听
        override fun finish() {
            if(!appViewModel.isHearPlay.value){
                return
            }
            val paragraphListValue = paragraphList.value ?: return
            val currentParagraphPos = paragraphPos.value // 当前段落位置
            val currentMPos = mPos.value  // 当前播放句
            when {
                isSingle.value -> { // 单段循环
                    val nextPos = currentMPos + 1
                    val paragraph = paragraphListValue.getOrNull(currentParagraphPos) ?: return
                    val start = paragraph.startIdx
                    val end = paragraph.endIdx
                    if (currentMPos == end) {
                        mPos.value = start
                    } else {
                        mPos.value = nextPos
                    }
                    playVoice(mPos.value)
                }

                isAbPlay.value -> { // AB复读
                    val index = currentMPos + 1
                    val aParagraph = paragraphListValue.getOrNull(poiA.value) ?: return
                    val bParagraph = paragraphListValue.getOrNull(poiB.value) ?: return
                    val start = aParagraph.startIdx
                    val end = bParagraph.endIdx
                    val paragraphEnd = paragraphListValue.getOrNull(currentParagraphPos)?.endIdx
                    when {
                        index > end || index < start -> {
                            paragraphPos.value = poiA.value
                        }
                        paragraphEnd != null && index > paragraphEnd -> {
                            paragraphPos.value += 1
                        }
                        else -> {
                            mPos.value = index
                        }
                    }
                }

                else -> { // 自动播放
                    val index = currentMPos + 1
                    val paragraph = paragraphListValue.getOrNull(currentParagraphPos) ?: return
                    val end = paragraph.endIdx
                    val readListSize = readList.value?.size ?: 0

                    if (index > readListSize - 1) {
                        
                        paragraphPos.value = 0
                        mPos.value = 0
                    } else if (index > end) {
                        paragraphPos.value += 1
                    } else {
                        mPos.value = index
                    }
                }
            }
        }

        override fun setTotal(total: Int) {

        }

        override fun setProgress(progress: Int) {

        }
    }

用户点击段落 → paragraphPos 变化 → mPos = paragraph.startIdx → playVoice() 播放完成 → finish() → 计算下一首 mPos → mPos 变化 → playVoice()

播放工具类MediaCommonUtil ,可参考可复制。

kotlin 复制代码
object MediaCommonUtil {

    private const val TAG = "MediaCommonUtil"
    private const val ENCODE = 1001 //URl转换指令
    private const val PROGRESS = 1002 //进度指令
    private const val PROGRESS_TIME = 100L

    private var mediaPlayer: MediaPlayer? = null

    private var mUrl = "" //播放Url
    private var mUrls = mutableListOf<String>()
    private var mPos = 0
    private var mCallback: Callback? = null //回调
    private var mSpeed = 1.0f //语速
    private var mFocus = true //抢占音频焦点
    private var mFocusListen: AudioManager.OnAudioFocusChangeListener? = null

    private var isPause = false //是否是暂停状态

    var playType = 0 //播放类型 0播放其他  1播放听力

    /**
     * 播放url
     */
    fun playMusic(url: String, callback: Callback?, speed: Float, focus: Boolean = true, type: Int,focusListen: AudioManager.OnAudioFocusChangeListener? = null) {
        stopMedia()
        playType = type
        mUrls = mutableListOf()
        mPos = 0
        mUrl = url
        mCallback = callback
        mSpeed = speed
        mFocus = focus
        mFocusListen = focusListen
        isPause = false
        if (url.isEmpty()) {
            mCallback?.finish()
            return
        }
        Thread {
            if (EncodeUtil.isEncryptedUrl(mUrl)) {
                mUrl = EncodeUtil.getEncodeUrl(mUrl)
            }
            Log.d(TAG, "playMusic: mUrl=$mUrl")
            handler.removeCallbacksAndMessages(null)
            handler.sendEmptyMessage(ENCODE)
        }.start()
    }

    fun playMusic(urls: MutableList<String>, pos: Int, callback: Callback?, speed: Float, focus: Boolean = true) {
        stopMedia()
        mUrls = urls
        mPos = pos
        mUrl = ""
        mCallback = callback
        mSpeed = speed
        mFocus = focus
        mFocusListen = null
        isPause = false
        if (mUrls.isEmpty()) {
            mCallback?.finish()
            return
        }
        mUrl = urls[pos]
        Thread {
            if (EncodeUtil.isEncryptedUrl(mUrl)) {
                mUrl = EncodeUtil.getEncodeUrl(mUrl)
            }
            Log.d(TAG, "playMusic: mUrl=$mUrl")
            handler.removeCallbacksAndMessages(null)
            handler.sendEmptyMessage(ENCODE)
        }.start()
    }

    /**
     * 停止播放
     */
    fun stopMedia() {
        AppUtil.abandonAudioFocus(mFocusListen)
        mediaPlayer?.stop()
        mediaPlayer?.release()
        mediaPlayer = null
        isPause = false
        mCallback = null
        handler.removeCallbacksAndMessages(null)
    }

    /**
     * 恢复播放
     */
    fun resumeMedia() {
        mediaPlayer?.start()
        isPause = false
        setSpeed(mSpeed)
    }

    /**
     * 暂停播放
     */
    fun pauseMedia() {
        mediaPlayer?.pause()
        isPause = true
    }

    /**
     * 是否在播放
     */
    fun isPlaying(): Boolean {
        return mediaPlayer?.isPlaying ?: false
    }

    /**
     * 是否是暂停状态
     */
    fun isPause(): Boolean {
        return isPause
    }

    /**
     * 从哪里播放
     */
    fun setSeek(progress: Int) {
        mediaPlayer?.seekTo(progress)
    }

    /**
     * 设置语速
     * 注意:Android M(API 23)及以上才支持 playbackParams 控制语速
     */
    fun setSpeed(speed: Float) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
            return
        }
        // 校验速度参数是否合法(必须大于 0)
        if (speed <= 0f) {
            throw IllegalArgumentException("Speed must be greater than 0, got $speed")
        }
        mSpeed = speed
        if (!isPlaying()) {
            return
        }
        val player = mediaPlayer ?: run {
            // 可选:添加日志便于调试
            // Log.w(TAG, "MediaPlayer is null when setting speed")
            return
        }
        try {
            val params = player.playbackParams
            params.speed = speed
            player.playbackParams = params
        }catch (e: Exception){
            e.printStackTrace()
        }
    }


    /**
     * URl转换后开始播放
     */
    private fun playUrl() {
        if (mFocus) {
            AppUtil.requestAudioFocus(mFocusListen) //这个判断主要是趣味配音结束要同时播放音频和视频,视频内部做了焦点处理,会停止播放
        }
        try {
            EncodeUtil.startMediaService(App.instance)
            mediaPlayer = MediaPlayer()
            mediaPlayer?.setDataSource(mUrl)
            mediaPlayer?.setOnPreparedListener {
                //播放
                it.start()
                //设置语速
                setSpeed(mSpeed)
                //回调进度和周期刷新进度
                mCallback?.setTotal(it.duration)
                handler.sendEmptyMessage(PROGRESS)
            }
            mediaPlayer?.setOnCompletionListener {
                handler.removeCallbacksAndMessages(null)
                if (mUrls.size > 0 && mPos < mUrls.size - 1) {
                    playMusic(mUrls, mPos + 1, mCallback, mSpeed)
                    return@setOnCompletionListener
                }
                mCallback?.finish()
            }
            mediaPlayer?.prepareAsync()
        } catch (e: IOException) {
            e.printStackTrace()
            stopMedia()
            mCallback?.finish()
        }
    }

    @SuppressLint("HandlerLeak")
    private val handler: Handler = object : Handler() {
        override fun handleMessage(msg: Message) {
            super.handleMessage(msg)
            if (msg.what == PROGRESS) {
                //刷新进度
                mediaPlayer?.let {
                    mCallback?.setProgress(it.currentPosition)
                    sendEmptyMessageDelayed(PROGRESS, PROGRESS_TIME)
                }
            } else if (msg.what == ENCODE) {
                //URL转换
                playUrl()
            }
        }
    }

    interface Callback {

        //播放完成
        fun finish()

        //总进度
        fun setTotal(total: Int)

        //当前进度
        fun setProgress(progress: Int)
    }
}

列表的Adapter中ui的变化的代码

ini 复制代码
var setAb: Boolean = false //设置ab中
    var pointA: Int = -1
    var pointB: Int = -1 // ab位置


override fun convert(holder: BaseDataBindingHolder<ItemReadTextBinding>, item: ParagraphBean) {
        holder.dataBinding?.let { it ->
            it.bean = item

            // 先统一隐藏所有图标
            it.ivUn.visibility = View.GONE
            it.ivAb.visibility = View.GONE
            // 再根据条件显示对应的图标
            if (setAb) {
                it.ivUn.visibility = View.VISIBLE
            }
            if (pointA == holder.position) {
                it.ivAb.visibility = View.VISIBLE
                it.ivAb.setImageResource(R.mipmap.icon_a)
                it.ivUn.visibility = View.GONE
            }else if (pointB == holder.position) {
                it.ivAb.visibility = View.VISIBLE
                it.ivAb.setImageResource(R.mipmap.icon_b)
                it.ivUn.visibility = View.GONE
            }
        }
    }

页面中Fragment的代码

kotlin 复制代码
adapter = ReadTextAdapter(mutableListOf()).apply {
            setOnItemClickListener { _, _, position ->
                if(adapter?.setAb == true){ //设置状态下 
                    setAbPoint(position);//设置AB点
                }else{ //正常情况 指针赋值播放该段
                    Log.e(TAG, "ReadTextAdapter : $position")
                    mViewModel.paragraphPos.value = position
                }

            }
        }


 override fun createObserver() {
        super.createObserver()
        mViewModel.paragraphList.observe(this) { //段落的列表监听
            adapter?.setList(it) //设置列表
            if (it != null) {
                if (it.isNotEmpty()) {
                    mViewModel.getPos()//获取上一次的播放位置
                }
            }
        }
        mViewModel.readList.observe(this) { //源数据(从本地或接口你的业务逻辑获取) 监听
            if (it.isNotEmpty()) {
                mViewModel.getParagraph() // 获取段落信息
            }
        }
        mViewModel.paragraphPos.observe(viewLifecycleOwner) { pos -> //段落位置监听
            Log.d(TAG, "paragraphPos : $pos")
            adapter?.setSel(pos)
            val paragraphList = mViewModel.paragraphList.value ?: run {
                Log.w(TAG, "paragraphList is null")
                return@observe
            }
            if (pos < 0 || pos >= paragraphList.size) {
                Log.w(TAG, "Invalid position: pos=$pos, listSize=${paragraphList.size}")
                return@observe
            }
            val startIdx = paragraphList[pos].startIdx
            mViewModel.mPos.value = startIdx //设置句的播放位置
            
        }

        mViewModel.mPos.observe(this) { // 句播放位置
            mViewModel.playVoice(it)
        }

        appViewModel.isHearPlay.observe(this) {//播放/暂停 状态监听
            if(!it){
                mDatabind.ivReadBf.setImageResource(R.drawable.icon_bf)//设置为播放图标
                MediaCommonUtil.pauseMedia()
                appViewModel.saveReadPoi(appViewModel.themeId.value, appViewModel.unitName.value, appViewModel.tytId.value, mViewModel.paragraphPos.value) // 保存播放位置
            }else{
                if(isAdded){
                    mDatabind.ivReadBf.setImageResource(R.drawable.icon_zt)//设置为暂停图标
                    if(MediaCommonUtil.playType==1){
                        MediaCommonUtil.resumeMedia()
                    }else{
                        mViewModel.playVoice(mViewModel.mPos.value) //播放音频
                    }
                }
            }
        }
    }

 inner class ProxyClick { //ui上的点击按钮
        fun previous(){ //上一首
            if (!TimeInterval.isFastClick()) {
                return
            }
            clearState()
            eventLister?.click(0)
        }
        fun playOrPause(){ //播放或暂停
            if (!TimeInterval.isFastClick()) {
                return
            }
            appViewModel.isHearPlay.value = !appViewModel.isHearPlay.value
        }

        fun next(){ //下一首
            if (!TimeInterval.isFastClick()) {
                return
            }
            clearState()
            eventLister?.click(1)
        }

        fun collect(){ //收藏
            if (!TimeInterval.isFastClick()) {
                return
            }
            mViewModel.isCollect.value = !mViewModel.isCollect.value
            mDatabind.ivCollect.isSelected = mViewModel.isCollect.value
            eventLister?.click(2)
            if (mViewModel.isCollect.value) {
                showToast("收藏成功")
            } else {
                showToast("取消收藏")
            }
        }

        fun single(){ //段落循环 
            if (!TimeInterval.isFastClick()) {
                return
            }
            mViewModel.isSingle.value = !mViewModel.isSingle.value
            mDatabind.ivDjfd.isSelected = mViewModel.isSingle.value
            if(mViewModel.isSingle.value){
                showToast("段落循环已开启")
            }else{
                showToast("段落循环已关闭")
            }
            cancelAb()
        }
        fun abRead(){ //ab读
            if (!TimeInterval.isFastClick()) {
                return
            }
            val size = mViewModel.paragraphList.value?.size ?: 0
            if(size < 2){
                showToast("请选择两个段落进行AB复读")
                return
            }
            cancelDjfd()
            if (mViewModel.isAbPlay.value) { //如果正在ab读 取消ab读
                cancelAb()
            } else {
                adapter?.let { adapter ->
                    val newSetAb = !adapter.setAb //取消和设置ab读
                    adapter.setAb = newSetAb
                    if (!newSetAb) {
                        cancelAb()
                    } else {
                        adapter.notifyDataSetChanged()
                    }
                }
            }


        }
        fun selectSpeed(){ //选择语速
            mViewModel.showSpeedSelect.value = !mViewModel.showSpeedSelect.value
        }

        fun speed1(){ //语速1.5X
            mViewModel.setPlaySpeed(1.5f)
            mViewModel.showSpeedSelect.value = false
        }
        fun speed2(){ //语速1.2X
            mViewModel.setPlaySpeed(1.2f)
            mViewModel.showSpeedSelect.value = false
        }
        fun speed3(){ //语速1.0X
            mViewModel.setPlaySpeed(1.0f)
            mViewModel.showSpeedSelect.value = false
        }
        fun speed4(){ //语速0.7X
            mViewModel.setPlaySpeed(0.7f)
            mViewModel.showSpeedSelect.value = false
        }
    }

fun cancelDjfd(){//取消单句循环
        mViewModel.isSingle.value = false
        mDatabind.ivDjfd.isSelected = false
    }
    fun cancelAb(){//取消ab读
        adapter?.pointA = -1
        adapter?.pointB = -1
        adapter?.setAb = false
        mViewModel.poiA.value = -1
        mViewModel.poiB.value = -1
        mViewModel.isAbPlay.value = false
        context?.resources?.let { mDatabind.tvTextA.setTextColor(it.getColor(R.color.col_666666)) }
        context?.resources?.let { mDatabind.tvText1.setTextColor(it.getColor(R.color.col_666666)) }
        context?.resources?.let { mDatabind.tvTextB.setTextColor(it.getColor(R.color.col_666666)) }
        adapter?.notifyDataSetChanged()
    }

    fun setAbPoint(point: Int) { // 设置ab点
        if (adapter?.pointA == -1 || adapter?.pointA!! >= point) { // A点
            adapter?.pointA = point
            mViewModel.poiA.value = point
            context?.resources?.let { mDatabind.tvTextA.setTextColor(it.getColor(R.color.col_00ADEF)) }
            context?.resources?.let { mDatabind.tvText1.setTextColor(it.getColor(R.color.col_00ADEF)) }
        }else if (adapter?.pointB == -1) { // B点
            adapter?.pointB = point
            mViewModel.poiB.value = point
            context?.resources?.let { mDatabind.tvTextB.setTextColor(it.getColor(R.color.col_00ADEF)) }
        }
        if(adapter?.pointA != -1 && adapter?.pointB != -1){ // 两个点都设置完成
            adapter!!.setAb = false
            mViewModel.isAbPlay.value = true // ab读生效
            appViewModel.isHearPlay.value = true
            mViewModel.paragraphPos.value = mViewModel.poiA.value
        }
        adapter?.notifyDataSetChanged()
    }
ini 复制代码
点击"AB复读"按钮 → adapter.setAb = true(进入设置态)
  → 点击段落A位置 → 记录 pointA,UI 显示 A 图标
  → 点击段落B位置 → 记录 pointB,UI 显示 B 图标
  → 两点都设置完成 → adapter.setAb = false,isAbPlay = true,自动从 A 点播放

注意 :这里有一个隐含规则------A 点必须小于等于 B 点。代码里做了 adapter?.pointA!! >= point 的判断,如果用户先点了后面的段落当 A,再点前面的当 B,会自动覆盖 A 点。

相关推荐
赏金术士11 小时前
企业级 Jetpack Compose 项目(入门版)最佳结构
android·kotlin·compose
我是唐青枫12 小时前
Kotlin Lambda 表达式详解:从基础语法到实战封装
开发语言·kotlin
Kapaseker14 小时前
Kotlin 的扩展没有你看上去的那么简单
android·kotlin
黄林晴14 小时前
告别 KMP 选型地狱!klibs.io 上线,全平台库一键筛选太省心
android·kotlin
吕氏春秋i15 小时前
android kotlin Compose 蓝牙库推荐
android·gitee·kotlin
鹏晨互联15 小时前
《Kotlin高阶函数完全指南:从入门到精通的15个核心函数》
android·开发语言·kotlin
android_cai_niao1 天前
快速删除集合中的元素
kotlin·removeif
雨白1 天前
深入理解 Kotlin 协程 (七):画地为营,解构协程作用域与父子羁绊
kotlin
唐青枫1 天前
Kotlin Lambda 表达式详解:从基础语法到实战封装
kotlin