sound 播放逻辑
This commit is contained in:
parent
8f171d3214
commit
1eb8b02ef0
|
|
@ -6,14 +6,17 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Parcelize
|
||||
data class ChatSound(
|
||||
val ttsId: Long,
|
||||
val language: Int,
|
||||
val desLanguage: String,
|
||||
val gender: Int,
|
||||
val rules: Int,
|
||||
val nameLanguage: String,
|
||||
val headPortrait: String,
|
||||
var isSelected: Boolean = false
|
||||
val ttsId: Long = 0,
|
||||
val language: Int = 0,
|
||||
val desLanguage: String = "",
|
||||
val gender: Int = 1,
|
||||
val rules: Int = 0,
|
||||
val nameLanguage: String = "",
|
||||
val headPortrait: String = "",
|
||||
var isSelected: Boolean = false,
|
||||
|
||||
// Other needed
|
||||
var sampleUrl: String = ""
|
||||
) : Parcelable {
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,33 @@ class ChatSettingView @JvmOverloads constructor(
|
|||
|
||||
|
||||
fun initSoundSelectorView() {
|
||||
val items = listOf(
|
||||
ChatSound(
|
||||
ttsId = 1,
|
||||
gender = 2,
|
||||
nameLanguage = "名称-1"
|
||||
),
|
||||
ChatSound(
|
||||
ttsId = 1,
|
||||
gender = 2,
|
||||
nameLanguage = "名称-2"
|
||||
),
|
||||
ChatSound(
|
||||
ttsId = 1,
|
||||
gender = 1,
|
||||
nameLanguage = "名称-3"
|
||||
),
|
||||
ChatSound(
|
||||
ttsId = 1,
|
||||
gender = 1,
|
||||
nameLanguage = "名称-4"
|
||||
),
|
||||
ChatSound(
|
||||
ttsId = 1,
|
||||
gender = 2,
|
||||
nameLanguage = "名称-5"
|
||||
),
|
||||
)
|
||||
/*val items = listOf(
|
||||
ChatSound(
|
||||
id = 1L,
|
||||
|
|
@ -214,7 +241,7 @@ class ChatSettingView @JvmOverloads constructor(
|
|||
)
|
||||
*/
|
||||
with(mBinding.soundActorSelector) {
|
||||
//setItems(items)
|
||||
setItems(items)
|
||||
setEventListener(
|
||||
object : ExpandSoundSelectView.IEventListener {
|
||||
override fun onItemSelected(
|
||||
|
|
|
|||
|
|
@ -5,21 +5,27 @@ import android.animation.Animator
|
|||
import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import com.remax.visualnovel.R
|
||||
import com.remax.visualnovel.configs.NovelApplication
|
||||
import com.remax.visualnovel.databinding.LayoutExpandSelectViewBinding
|
||||
import com.remax.visualnovel.entity.response.ChatSound
|
||||
import com.remax.visualnovel.utils.media.AudioPlayableView
|
||||
import java.io.File
|
||||
|
||||
|
||||
class ExpandSoundSelectView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : android.widget.LinearLayout(context, attrs, defStyleAttr) {
|
||||
) : AudioPlayableView(context, attrs, defStyleAttr) {
|
||||
private lateinit var mBinding: LayoutExpandSelectViewBinding
|
||||
private lateinit var mExpandView : ExpandSoundSubView
|
||||
private var isExpanded = false
|
||||
|
|
@ -56,6 +62,10 @@ class ExpandSoundSelectView @JvmOverloads constructor(
|
|||
override fun onFilterChanged(sexValue: Int) {
|
||||
mEventListener.onFiltersChanged(sexValue)
|
||||
}
|
||||
|
||||
override fun onPlaySample(soundItem: ChatSound) {
|
||||
playSoundSample(soundItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -150,5 +160,88 @@ class ExpandSoundSelectView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
|
||||
private fun playSoundSample(chatSound: ChatSound) {
|
||||
if (audioPlayer.isPlaying()) {
|
||||
pauseAudio()
|
||||
}
|
||||
|
||||
if (!chatSound.sampleUrl.isEmpty()) {
|
||||
playAudio( chatSound.sampleUrl)
|
||||
} else {
|
||||
playAudio(NovelApplication.appContext().getExternalFilesDir(null)?.path + "/ringtone.mp3")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onAudioStarted() {
|
||||
post {
|
||||
// 更新UI为暂停状态
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioPaused() {
|
||||
post {
|
||||
// 更新UI为播放状态
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAudioError(errorMessage: String) {
|
||||
post {
|
||||
Toast.makeText(context, "播放失败: $errorMessage", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
// 在Activity中使用
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var audioPlayer: AudioPlayerManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
audioPlayer = AudioPlayerManager.getInstance(this)
|
||||
|
||||
setupAudioViews()
|
||||
}
|
||||
|
||||
private fun setupAudioViews() {
|
||||
// 按钮1播放网络音频
|
||||
findViewById<View>(R.id.btnPlay1).setOnClickListener {
|
||||
audioPlayer.play("https://example.com/audio1.mp3", object : AudioPlayListener {
|
||||
override fun onStateChanged(state: AudioPlayState, source: String) {
|
||||
// 只监听这个音频源的状态
|
||||
updateButton1State(state)
|
||||
}
|
||||
override fun onProgressChanged(position: Int, duration: Int, source: String) {}
|
||||
override fun onBufferingUpdate(percent: Int, source: String) {}
|
||||
})
|
||||
}
|
||||
|
||||
// 按钮2播放本地文件
|
||||
findViewById<View>(R.id.btnPlay2).setOnClickListener {
|
||||
val file = File(getExternalFilesDir("audio"), "local.mp3")
|
||||
audioPlayer.play(file, object : AudioPlayListener {
|
||||
override fun onStateChanged(state: AudioPlayState, source: String) {
|
||||
updateButton2State(state)
|
||||
}
|
||||
override fun onProgressChanged(position: Int, duration: Int, source: String) {}
|
||||
override fun onBufferingUpdate(percent: Int, source: String) {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// 根据需求决定是否释放单例
|
||||
// AudioPlayerManager.releaseInstance()
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ExpandSoundSubView @JvmOverloads constructor(
|
|||
interface IEventListener {
|
||||
fun onSoundSelected(sound: ChatSound)
|
||||
fun onFilterChanged(setValue: Int)
|
||||
fun onPlaySample(soundItem: ChatSound)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -68,10 +69,6 @@ class ExpandSoundSubView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun playActorSound(bubble : ChatSound) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun setEventListener(eventListener: IEventListener) {
|
||||
mEventListener = eventListener
|
||||
|
|
@ -86,8 +83,8 @@ class ExpandSoundSubView @JvmOverloads constructor(
|
|||
addType<ChatSound>(R.layout.layout_item_setting_sound)
|
||||
|
||||
onClick(R.id.left_container) {
|
||||
val bubble = getModel<ChatSound>()
|
||||
playActorSound(bubble)
|
||||
val sound = getModel<ChatSound>()
|
||||
mEventListener.onPlaySample(sound)
|
||||
}
|
||||
|
||||
onClick(R.id.item_root) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
package com.remax.visualnovel.utils.media
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import java.io.File
|
||||
|
||||
|
||||
abstract class AudioPlayableView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr), AudioPlayListener {
|
||||
|
||||
protected val audioPlayer = GlobalAudioPlayerManager.getInstance()
|
||||
protected var currentAudioSource: String = ""
|
||||
private var lifecycleObserver: LifecycleEventObserver? = null
|
||||
|
||||
|
||||
|
||||
fun playAudio(source: Any, autoStopOnDetach: Boolean = true) {
|
||||
currentAudioSource = when (source) {
|
||||
is String -> source
|
||||
is Uri -> source.toString()
|
||||
is File -> source.absolutePath
|
||||
else -> return
|
||||
}
|
||||
|
||||
// Just listen current source
|
||||
audioPlayer.addListener(currentAudioSource, this, "view_${hashCode()}")
|
||||
if (!audioPlayer.play(source, this, "view_${hashCode()}")) {
|
||||
onPlayError("Play Error")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bind lifecycle from fragment or activity
|
||||
*/
|
||||
fun bindLifecycle(lifecycleOwner: LifecycleOwner) {
|
||||
lifecycleObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> pauseAudio()
|
||||
Lifecycle.Event.ON_RESUME -> { /* 可选的恢复逻辑 */ }
|
||||
Lifecycle.Event.ON_DESTROY -> releaseAudio()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(lifecycleObserver!!)
|
||||
}
|
||||
|
||||
/**
|
||||
* pause
|
||||
*/
|
||||
fun pauseAudio() {
|
||||
if (audioPlayer.getCurrentSource() == currentAudioSource) {
|
||||
audioPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stop
|
||||
*/
|
||||
fun stopAudio() {
|
||||
if (audioPlayer.getCurrentSource() == currentAudioSource) {
|
||||
audioPlayer.stop()
|
||||
}
|
||||
audioPlayer.removeListener(currentAudioSource, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* stop automatically while view detach
|
||||
*/
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
stopAudio()
|
||||
lifecycleObserver?.let { observer ->
|
||||
(context as? LifecycleOwner)?.lifecycle?.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* release
|
||||
*/
|
||||
fun releaseAudio() {
|
||||
stopAudio()
|
||||
currentAudioSource = ""
|
||||
}
|
||||
|
||||
|
||||
// Specified source state changed callback
|
||||
override fun onStateChanged(state: AudioPlayState, source: String) {
|
||||
if (source != currentAudioSource) return
|
||||
|
||||
when (state) {
|
||||
is AudioPlayState.Playing -> onAudioStarted()
|
||||
is AudioPlayState.Paused -> onAudioPaused()
|
||||
is AudioPlayState.Completed -> onAudioCompleted()
|
||||
is AudioPlayState.Error -> onAudioError(state.errorMessage)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgressChanged(position: Int, duration: Int, source: String) {
|
||||
if (source == currentAudioSource) {
|
||||
onAudioProgressChanged(position, duration)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBufferingUpdate(percent: Int, source: String) {
|
||||
if (source == currentAudioSource) {
|
||||
onAudioBufferingUpdate(percent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sub class could override methods
|
||||
open fun onPlayError(message: String) {}
|
||||
open fun onAudioStarted() {}
|
||||
open fun onAudioPaused() {}
|
||||
open fun onAudioCompleted() {}
|
||||
open fun onAudioError(errorMessage: String) {}
|
||||
open fun onAudioProgressChanged(position: Int, duration: Int) {}
|
||||
open fun onAudioBufferingUpdate(percent: Int) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
package com.remax.visualnovel.utils.media
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.remax.visualnovel.configs.NovelApplication
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
// Player State
|
||||
sealed class AudioPlayState {
|
||||
object Idle : AudioPlayState()
|
||||
object Preparing : AudioPlayState()
|
||||
object Playing : AudioPlayState()
|
||||
object Paused : AudioPlayState()
|
||||
object Stopped : AudioPlayState()
|
||||
data class Error(val errorCode: Int, val errorMessage: String, val source: String) : AudioPlayState()
|
||||
object Completed : AudioPlayState()
|
||||
}
|
||||
|
||||
// error code
|
||||
object AudioErrorCode {
|
||||
const val UNKNOWN_ERROR = 1000
|
||||
const val NETWORK_ERROR = 1001
|
||||
const val FILE_NOT_FOUND = 1002
|
||||
const val FORMAT_NOT_SUPPORTED = 1003
|
||||
const val PERMISSION_DENIED = 1004
|
||||
const val AUDIO_FOCUS_LOST = 1005
|
||||
const val PREPARE_FAILED = 1006
|
||||
const val AUDIO_FOCUS_REQUEST_FAILED = 1007
|
||||
const val INVALID_DATA_SOURCE = 1008
|
||||
}
|
||||
|
||||
// Player config
|
||||
data class AudioPlayerConfig(
|
||||
val streamType: Int = AudioManager.STREAM_MUSIC,
|
||||
val usage: Int = AudioAttributes.USAGE_MEDIA,
|
||||
val contentType: Int = AudioAttributes.CONTENT_TYPE_MUSIC,
|
||||
val focusGain: Int = AudioManager.AUDIOFOCUS_GAIN,
|
||||
val autoPlay: Boolean = true,
|
||||
val loop: Boolean = false,
|
||||
val timeoutMs: Long = 30000,
|
||||
val retryCount: Int = 2,
|
||||
val duckOnFocusLoss: Boolean = true
|
||||
)
|
||||
|
||||
|
||||
// Player state listener
|
||||
interface AudioPlayListener {
|
||||
fun onStateChanged(state: AudioPlayState, source: String)
|
||||
fun onProgressChanged(position: Int, duration: Int, source: String)
|
||||
fun onBufferingUpdate(percent: Int, source: String)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Single instance player manager
|
||||
class GlobalAudioPlayerManager private constructor(
|
||||
private val config: AudioPlayerConfig = AudioPlayerConfig()
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: GlobalAudioPlayerManager? = null
|
||||
|
||||
private val mAppContext: Context = NovelApplication.appContext()
|
||||
|
||||
fun getInstance(config: AudioPlayerConfig = AudioPlayerConfig()): GlobalAudioPlayerManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: GlobalAudioPlayerManager(config).also { instance = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseInstance() {
|
||||
instance?.release()
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Global audio focus manager
|
||||
private val audioManager: AudioManager by lazy {
|
||||
mAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
}
|
||||
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var currentState: AudioPlayState = AudioPlayState.Idle
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
|
||||
private var currentSource: String = ""
|
||||
private var previousSource: String = ""
|
||||
private var isAudioFocusGranted = false
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var wasPlayingBeforeFocusLoss = false
|
||||
|
||||
|
||||
// Listeners, just notify events about specified file(Map, key)
|
||||
private val sourceListeners = ConcurrentHashMap<String, CopyOnWriteArrayList<AudioPlayListener>>()
|
||||
|
||||
// progress runnable
|
||||
private var progressUpdateRunnable: Runnable? = null
|
||||
private var isReleased = false
|
||||
|
||||
init {
|
||||
initializeMediaPlayer()
|
||||
}
|
||||
|
||||
private fun initializeMediaPlayer() {
|
||||
if (isReleased) return
|
||||
|
||||
try {
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(config.usage)
|
||||
.setContentType(config.contentType)
|
||||
.build()
|
||||
)
|
||||
setOnPreparedListener(::onPrepared)
|
||||
setOnCompletionListener(::onCompletion)
|
||||
setOnErrorListener(::onError)
|
||||
setOnBufferingUpdateListener(::onBufferingUpdate)
|
||||
isLooping = config.loop
|
||||
}
|
||||
setState(AudioPlayState.Idle, currentSource)
|
||||
} catch (e: Exception) {
|
||||
handleError(AudioErrorCode.UNKNOWN_ERROR, "MediaPlayer init failed: ${e.message}", currentSource)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* play source, also pause previous playing source.
|
||||
*/
|
||||
fun play(source: Any, listener: AudioPlayListener? = null, tag: String = ""): Boolean {
|
||||
if (isReleased) {
|
||||
Log.w("AudioPlayer", "Player released, play failed")
|
||||
return false
|
||||
}
|
||||
|
||||
val sourceString = convertSourceToString(source)
|
||||
if (sourceString.isBlank()) {
|
||||
handleError(AudioErrorCode.INVALID_DATA_SOURCE, "Invalid source", sourceString)
|
||||
return false
|
||||
}
|
||||
|
||||
// 停止上一个播放(如果是不同的source)
|
||||
if (currentSource.isNotEmpty() && currentSource != sourceString) {
|
||||
stopCurrentPlayback()
|
||||
}
|
||||
|
||||
// 注册监听器
|
||||
listener?.let { addListener(sourceString, it, tag) }
|
||||
|
||||
currentSource = sourceString
|
||||
return startPlayback(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* 为特定source添加监听器
|
||||
*/
|
||||
fun addListener(source: Any, listener: AudioPlayListener, tag: String = "") {
|
||||
val sourceString = convertSourceToString(source)
|
||||
if (sourceString.isBlank()) return
|
||||
|
||||
sourceListeners.getOrPut(sourceString) { CopyOnWriteArrayList() }.add(listener)
|
||||
Log.d("AudioPlayer", "为source[$sourceString]添加监听器, 当前监听器数: ${sourceListeners[sourceString]?.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除特定source的监听器
|
||||
*/
|
||||
fun removeListener(source: Any, listener: AudioPlayListener) {
|
||||
val sourceString = convertSourceToString(source)
|
||||
sourceListeners[sourceString]?.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除特定tag的所有监听器
|
||||
*/
|
||||
fun removeListenersByTag(tag: String, source: Any? = null) {
|
||||
if (source != null) {
|
||||
val sourceString = convertSourceToString(source)
|
||||
sourceListeners[sourceString]?.removeAll { listener ->
|
||||
// 如果监听器有tag属性,可以根据需要实现
|
||||
true // 这里需要根据实际tag实现过滤逻辑
|
||||
}
|
||||
} else {
|
||||
sourceListeners.values.forEach { listeners ->
|
||||
listeners.removeAll { true } // 简化实现,实际需要根据tag过滤
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停播放
|
||||
*/
|
||||
fun pause(): Boolean {
|
||||
if (isReleased || currentState != AudioPlayState.Playing) return false
|
||||
|
||||
return try {
|
||||
mediaPlayer?.pause()
|
||||
setState(AudioPlayState.Paused, currentSource)
|
||||
stopProgressUpdates()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
handleError(AudioErrorCode.UNKNOWN_ERROR, "暂停失败: ${e.message}", currentSource)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复播放
|
||||
*/
|
||||
fun resume(): Boolean {
|
||||
if (isReleased || currentState != AudioPlayState.Paused) return false
|
||||
|
||||
if (!requestAudioFocus()) {
|
||||
handleError(AudioErrorCode.AUDIO_FOCUS_REQUEST_FAILED, "无法获取音频焦点", currentSource)
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
mediaPlayer?.start()
|
||||
setState(AudioPlayState.Playing, currentSource)
|
||||
startProgressUpdates()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
handleError(AudioErrorCode.UNKNOWN_ERROR, "恢复播放失败: ${e.message}", currentSource)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止播放
|
||||
*/
|
||||
fun stop(): Boolean {
|
||||
if (isReleased) return false
|
||||
|
||||
stopCurrentPlayback()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定位置
|
||||
*/
|
||||
fun seekTo(position: Int): Boolean {
|
||||
if (isReleased) return false
|
||||
|
||||
return try {
|
||||
mediaPlayer?.takeIf { it.duration > 0 }?.let { player ->
|
||||
val safePosition = position.coerceIn(0, player.duration)
|
||||
player.seekTo(safePosition)
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前播放的source
|
||||
*/
|
||||
fun getCurrentSource(): String = currentSource
|
||||
|
||||
/**
|
||||
* 是否正在播放
|
||||
*/
|
||||
fun isPlaying(): Boolean = currentState == AudioPlayState.Playing && !isReleased
|
||||
|
||||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
fun getCurrentState(): AudioPlayState = currentState
|
||||
|
||||
// endregion
|
||||
|
||||
// region 播放控制内部实现
|
||||
|
||||
private fun startPlayback(source: Any): Boolean {
|
||||
if (!requestAudioFocus()) {
|
||||
handleError(AudioErrorCode.AUDIO_FOCUS_REQUEST_FAILED, "无法获取音频焦点", currentSource)
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
setState(AudioPlayState.Preparing, currentSource)
|
||||
|
||||
mediaPlayer?.let { player ->
|
||||
player.reset()
|
||||
|
||||
when (source) {
|
||||
is String -> setupDataSourceFromString(source)
|
||||
is Uri -> player.setDataSource(mAppContext, source)
|
||||
is File -> {
|
||||
if (!source.exists()) {
|
||||
throw IOException("File not exist: ${source.absolutePath}")
|
||||
}
|
||||
player.setDataSource(source.absolutePath)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Not support media type")
|
||||
}
|
||||
|
||||
player.prepareAsync()
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
handlePlayException(e, "Play prepare failed")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDataSourceFromString(source: String) {
|
||||
mediaPlayer?.let { player ->
|
||||
when {
|
||||
source.startsWith("http://") || source.startsWith("https://") -> {
|
||||
player.setDataSource(source)
|
||||
}
|
||||
source.startsWith("file://") -> {
|
||||
player.setDataSource(source)
|
||||
}
|
||||
source.startsWith("content://") -> {
|
||||
player.setDataSource(mAppContext, Uri.parse(source))
|
||||
}
|
||||
else -> {
|
||||
// 尝试作为文件路径处理
|
||||
val file = File(source)
|
||||
if (file.exists()) {
|
||||
player.setDataSource(source)
|
||||
} else {
|
||||
throw IOException("File not exist: $source")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopCurrentPlayback() {
|
||||
try {
|
||||
mediaPlayer?.let { player ->
|
||||
if (player.isPlaying) {
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
setState(AudioPlayState.Stopped, currentSource)
|
||||
stopProgressUpdates()
|
||||
abandonAudioFocus()
|
||||
|
||||
// 通知上一个source的监听器停止
|
||||
if (previousSource.isNotEmpty()) {
|
||||
notifyStateChanged(AudioPlayState.Stopped, previousSource)
|
||||
}
|
||||
|
||||
previousSource = currentSource
|
||||
currentSource = ""
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "Stop playing failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
|
||||
|
||||
// region 音频焦点管理
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
if (isAudioFocusGranted) return true
|
||||
|
||||
return try {
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest = AudioFocusRequest.Builder(config.focusGain).apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(config.usage)
|
||||
.setContentType(config.contentType)
|
||||
.build()
|
||||
)
|
||||
setOnAudioFocusChangeListener(::onAudioFocusChange)
|
||||
setWillPauseWhenDucked(config.duckOnFocusLoss)
|
||||
}.build()
|
||||
|
||||
audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
::onAudioFocusChange,
|
||||
config.streamType,
|
||||
config.focusGain
|
||||
)
|
||||
}
|
||||
|
||||
isAudioFocusGranted = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
isAudioFocusGranted
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "Request audio focus failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
try {
|
||||
if (isAudioFocusGranted) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(::onAudioFocusChange)
|
||||
}
|
||||
isAudioFocusGranted = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "Release audio focus failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAudioFocusChange(focusChange: Int) {
|
||||
mainHandler.post {
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
onAudioFocusGained()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
onAudioFocusLost()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
onAudioFocusLostTransient()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
onAudioFocusDuck()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAudioFocusGained() {
|
||||
isAudioFocusGranted = true
|
||||
if (wasPlayingBeforeFocusLoss && currentState == AudioPlayState.Paused) {
|
||||
resume()
|
||||
}
|
||||
wasPlayingBeforeFocusLoss = false
|
||||
}
|
||||
|
||||
private fun onAudioFocusLost() {
|
||||
wasPlayingBeforeFocusLoss = currentState == AudioPlayState.Playing
|
||||
stop()
|
||||
}
|
||||
|
||||
private fun onAudioFocusLostTransient() {
|
||||
wasPlayingBeforeFocusLoss = currentState == AudioPlayState.Playing
|
||||
if (wasPlayingBeforeFocusLoss) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAudioFocusDuck() {
|
||||
if (config.duckOnFocusLoss) {
|
||||
mediaPlayer?.setVolume(0.2f, 0.2f)
|
||||
} else {
|
||||
wasPlayingBeforeFocusLoss = currentState == AudioPlayState.Playing
|
||||
if (wasPlayingBeforeFocusLoss) {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
|
||||
|
||||
// region MediaPlayer callback
|
||||
private fun onPrepared(mp: MediaPlayer) {
|
||||
mainHandler.post {
|
||||
try {
|
||||
if (config.autoPlay) {
|
||||
mp.start()
|
||||
setState(AudioPlayState.Playing, currentSource)
|
||||
startProgressUpdates()
|
||||
} else {
|
||||
setState(AudioPlayState.Paused, currentSource)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
handleError(AudioErrorCode.PREPARE_FAILED, "Player prepare failed: ${e.message}", currentSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCompletion(mp: MediaPlayer) {
|
||||
mainHandler.post {
|
||||
setState(AudioPlayState.Completed, currentSource)
|
||||
stopProgressUpdates()
|
||||
abandonAudioFocus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
|
||||
mainHandler.post {
|
||||
val errorMessage = when (what) {
|
||||
MediaPlayer.MEDIA_ERROR_UNKNOWN -> "Unknown error"
|
||||
MediaPlayer.MEDIA_ERROR_SERVER_DIED -> "Media server error"
|
||||
MediaPlayer.MEDIA_ERROR_IO -> "I/O ERROR"
|
||||
else -> "Media Error: what=$what, extra=$extra"
|
||||
}
|
||||
handleError(AudioErrorCode.PREPARE_FAILED, errorMessage, currentSource)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onBufferingUpdate(mp: MediaPlayer, percent: Int) {
|
||||
mainHandler.post {
|
||||
notifyBufferingUpdate(percent, currentSource)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
|
||||
// region progress
|
||||
private fun startProgressUpdates() {
|
||||
stopProgressUpdates()
|
||||
|
||||
progressUpdateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (currentState == AudioPlayState.Playing && !isReleased) {
|
||||
try {
|
||||
val position = mediaPlayer?.currentPosition ?: 0
|
||||
val duration = mediaPlayer?.duration ?: 0
|
||||
notifyProgressChanged(position, duration, currentSource)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
mainHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
mainHandler.post(progressUpdateRunnable!!)
|
||||
}
|
||||
|
||||
private fun stopProgressUpdates() {
|
||||
progressUpdateRunnable?.let {
|
||||
mainHandler.removeCallbacks(it)
|
||||
progressUpdateRunnable = null
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Various state notifications
|
||||
private fun setState(state: AudioPlayState, source: String) {
|
||||
if (currentState != state) {
|
||||
currentState = state
|
||||
notifyStateChanged(state, source)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStateChanged(state: AudioPlayState, source: String) {
|
||||
mainHandler.post {
|
||||
sourceListeners[source]?.forEach { listener ->
|
||||
try {
|
||||
listener.onStateChanged(state, source)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "onStateChanged exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyProgressChanged(position: Int, duration: Int, source: String) {
|
||||
mainHandler.post {
|
||||
sourceListeners[source]?.forEach { listener ->
|
||||
try {
|
||||
listener.onProgressChanged(position, duration, source)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "onProgressChanged Exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyBufferingUpdate(percent: Int, source: String) {
|
||||
mainHandler.post {
|
||||
sourceListeners[source]?.forEach { listener ->
|
||||
try {
|
||||
listener.onBufferingUpdate(percent, source)
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "onBufferingUpdate exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
|
||||
|
||||
// region 工具方法
|
||||
private fun convertSourceToString(source: Any): String {
|
||||
return when (source) {
|
||||
is String -> source
|
||||
is Uri -> source.toString()
|
||||
is File -> source.absolutePath
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayException(e: Exception, message: String) {
|
||||
val errorCode = when (e) {
|
||||
is IOException -> AudioErrorCode.NETWORK_ERROR
|
||||
is SecurityException -> AudioErrorCode.PERMISSION_DENIED
|
||||
is IllegalArgumentException -> AudioErrorCode.FORMAT_NOT_SUPPORTED
|
||||
else -> AudioErrorCode.UNKNOWN_ERROR
|
||||
}
|
||||
handleError(errorCode, "$message: ${e.message}", currentSource)
|
||||
}
|
||||
|
||||
private fun handleError(errorCode: Int, errorMessage: String, source: String) {
|
||||
Log.e("AudioPlayer", "Player Error[$errorCode]: $errorMessage, source: $source")
|
||||
setState(AudioPlayState.Error(errorCode, errorMessage, source), source)
|
||||
stopProgressUpdates()
|
||||
abandonAudioFocus()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region release related
|
||||
fun release() {
|
||||
if (isReleased) return
|
||||
|
||||
isReleased = true
|
||||
stopCurrentPlayback()
|
||||
stopProgressUpdates()
|
||||
sourceListeners.clear()
|
||||
abandonAudioFocus()
|
||||
|
||||
try {
|
||||
mediaPlayer?.release()
|
||||
mediaPlayer = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("AudioPlayer", "Release player failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -64,17 +64,17 @@ open class IconFontTextView @JvmOverloads constructor(context: Context, attrs: A
|
|||
var endDrawable: IconFontDrawable? = null
|
||||
var topDrawable: IconFontDrawable? = null
|
||||
var bottomDrawable: IconFontDrawable? = null
|
||||
if (!startIconFont.isNullOrEmpty()) {
|
||||
if (!startIconFont.isNullOrBlank()) {
|
||||
startDrawable = IconFontDrawable(context, startIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp))
|
||||
}
|
||||
if (!endIconFont.isNullOrEmpty()) {
|
||||
if (!endIconFont.isNullOrBlank()) {
|
||||
endDrawable =
|
||||
IconFontDrawable(context, endIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp))
|
||||
}
|
||||
if (!topIconFont.isNullOrEmpty()) {
|
||||
if (!topIconFont.isNullOrBlank()) {
|
||||
topDrawable = IconFontDrawable(context, topIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp))
|
||||
}
|
||||
if (!bottomIconFont.isNullOrEmpty()) {
|
||||
if (!bottomIconFont.isNullOrBlank()) {
|
||||
bottomDrawable = IconFontDrawable(context, bottomIconFont).color(getFontColor(iconColorToken)).sizePx(getFontSize(iconSize, isDp))
|
||||
}
|
||||
if (startIconFont == null && topIconFont == null && endIconFont == null && bottomIconFont == null) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
app:advRadius="@dimen/dp_28" />
|
||||
|
||||
<com.remax.visualnovel.widget.uitoken.view.UITokenImageView
|
||||
android:id="@+id/iv_play_sample"
|
||||
android:layout_width="@dimen/dp_20"
|
||||
android:layout_height="@dimen/dp_20"
|
||||
android:layout_gravity="end|top"
|
||||
|
|
|
|||
Loading…
Reference in New Issue