diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSound.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSound.kt index 2b35f09..3cfc772 100644 --- a/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSound.kt +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/entity/response/ChatSound.kt @@ -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 - ) : Parcelable { + 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 { } diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/ChatSettingView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/ChatSettingView.kt index 2d351de..ad3adaa 100644 --- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/ChatSettingView.kt +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/ChatSettingView.kt @@ -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( diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSelectView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSelectView.kt index f9cc09d..455b8b1 100644 --- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSelectView.kt +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSelectView.kt @@ -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(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(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() + } + } + */ -} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSubView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSubView.kt index 4c7b07a..c4ed132 100644 --- a/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSubView.kt +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/ui/chat/setting/customui/expandableSelector/ExpandSoundSubView.kt @@ -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(R.layout.layout_item_setting_sound) onClick(R.id.left_container) { - val bubble = getModel() - playActorSound(bubble) + val sound = getModel() + mEventListener.onPlaySample(sound) } onClick(R.id.item_root) { diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/AudioPlayableView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/AudioPlayableView.kt new file mode 100644 index 0000000..7543b56 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/AudioPlayableView.kt @@ -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) {} +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/GlobalAudioPlayer.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/GlobalAudioPlayer.kt new file mode 100644 index 0000000..2c76ea2 --- /dev/null +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/utils/media/GlobalAudioPlayer.kt @@ -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>() + + // 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 +} \ No newline at end of file diff --git a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt index 1d45e37..4ce054a 100644 --- a/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt +++ b/VisualNovel/app/src/main/java/com/remax/visualnovel/widget/ui/IconFontTextView.kt @@ -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) { diff --git a/VisualNovel/app/src/main/res/layout/layout_item_setting_sound.xml b/VisualNovel/app/src/main/res/layout/layout_item_setting_sound.xml index a4cc1d7..c9c67d8 100644 --- a/VisualNovel/app/src/main/res/layout/layout_item_setting_sound.xml +++ b/VisualNovel/app/src/main/res/layout/layout_item_setting_sound.xml @@ -25,6 +25,7 @@ app:advRadius="@dimen/dp_28" />