本文分享一套在真实项目中沉淀的方案: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 点。