Просмотр исходного кода

feat: 重写有声书界面,添加跳过片头片尾、音频增减益功能喵

HapeLee 2 месяцев назад
Родитель
Сommit
b68e0aeb38
40 измененных файлов с 2352 добавлено и 1883 удалено
  1. 1 6
      app/src/main/AndroidManifest.xml
  2. 1 0
      app/src/main/java/io/legado/app/constant/IntentAction.kt
  3. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  4. 42 1
      app/src/main/java/io/legado/app/data/entities/Book.kt
  5. 14 8
      app/src/main/java/io/legado/app/di/appModule.kt
  6. 36 5
      app/src/main/java/io/legado/app/model/AudioPlay.kt
  7. 1 5
      app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt
  8. 50 5
      app/src/main/java/io/legado/app/service/AudioPlayService.kt
  9. 0 821
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  10. 85 0
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayContract.kt
  11. 290 0
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayCoordinator.kt
  12. 723 0
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt
  13. 235 79
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt
  14. 5 13
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  15. 0 13
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  16. 2 9
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  17. 22 419
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  18. 2 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  19. 50 1
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  20. 11 0
      app/src/main/java/io/legado/app/ui/main/MainIntent.kt
  21. 150 0
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  22. 7 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  23. 24 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  24. 12 1
      app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt
  25. 5 6
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt
  26. 86 0
      app/src/main/java/io/legado/app/ui/widget/components/player/PlayerAdjustmentSlider.kt
  27. 151 0
      app/src/main/java/io/legado/app/ui/widget/components/player/PlayerBackground.kt
  28. 124 0
      app/src/main/java/io/legado/app/ui/widget/components/player/PlayerProgressSlider.kt
  29. 171 0
      app/src/main/java/io/legado/app/ui/widget/components/player/PlayerTocPage.kt
  30. 2 3
      app/src/main/java/io/legado/app/utils/ContextExtensions.kt
  31. 1 2
      app/src/main/java/io/legado/app/utils/FragmentExtensions.kt
  32. 0 11
      app/src/main/res/drawable/ic_play_mode_list_end_stop.xml
  33. 0 11
      app/src/main/res/drawable/ic_play_mode_list_loop.xml
  34. 0 417
      app/src/main/res/layout/activity_audio_play.xml
  35. 0 45
      app/src/main/res/menu/audio_play.xml
  36. 12 0
      app/src/main/res/values-zh-rCN/strings.xml
  37. 12 0
      app/src/main/res/values-zh-rHK/strings.xml
  38. 12 0
      app/src/main/res/values-zh-rTW/strings.xml
  39. 12 0
      app/src/main/res/values/strings.xml
  40. 0 1
      build.gradle.kts

+ 1 - 6
app/src/main/AndroidManifest.xml

@@ -224,12 +224,7 @@
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTask"
             android:windowSoftInputMode="adjustResize|stateHidden" />
-        <!-- 音频播放界面 -->
-        <activity
-            android:name="io.legado.app.ui.book.audio.AudioPlayActivity"
-            android:configChanges="locale|layoutDirection|uiMode|screenLayout"
-            android:enableOnBackInvokedCallback="true"
-            android:launchMode="singleTask" />
+        <!-- 音频播放界面(nav3 路由,见 MainRouteAudioPlay) -->
         <!-- 授权界面 -->
         <activity
             android:name="io.legado.app.lib.permission.PermissionActivity"

+ 1 - 0
app/src/main/java/io/legado/app/constant/IntentAction.kt

@@ -19,6 +19,7 @@ object IntentAction {
     const val upTtsProgress = "upTtsProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustSpeed = "adjustSpeed"
+    const val adjustGain = "adjustGain"
     const val prev = "prev"
     const val next = "next"
     const val moveTo = "moveTo"

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -167,6 +167,7 @@ object PreferKey {
     const val capsuleAutoCollapse = "capsuleAutoCollapse"
     const val defaultReadAloudInterface = "defaultReadAloudInterface"
     const val readAloudPlayerBgMode = "readAloudPlayerBgMode"
+    const val audioPlayBgMode = "audioPlayBgMode"
     const val showLastUpdateTime = "showLastUpdateTime"
     const val showWaitUpCount = "showWaitUpCount"
     const val clearWebViewData = "clearWebViewData"

+ 42 - 1
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -192,6 +192,42 @@ data class Book(
             return readConfig!!
         }
 
+    // 音频片头(秒)的 setter 和 getter
+    fun setOpenCredits(openCredits: Int) {
+        config.openCredits = openCredits
+    }
+
+    fun getOpenCredits(): Int {
+        return config.openCredits
+    }
+
+    // 音频片尾(秒)的 setter 和 getter
+    fun setCloseCredits(closeCredits: Int) {
+        config.closeCredits = closeCredits
+    }
+
+    fun getCloseCredits(): Int {
+        return config.closeCredits
+    }
+
+    // 音频播放模式 的 setter 和 getter
+    fun setPlayMode(playMode: Int) {
+        config.playMode = playMode
+    }
+
+    fun getPlayMode(): Int {
+        return config.playMode
+    }
+
+    // 音频增益(mB) 的 setter 和 getter
+    fun setAudioGain(audioGain: Int) {
+        config.audioGain = audioGain
+    }
+
+    fun getAudioGain(): Int {
+        return config.audioGain
+    }
+
     fun setReverseToc(reverseToc: Boolean) {
         config.reverseToc = reverseToc
     }
@@ -447,7 +483,12 @@ data class Book(
 
         var fixedType: Boolean = false, // 固定书籍类型,不随书源更新
 
-        var translationMode: Boolean = false // 是否启用翻译阅读模式
+        var translationMode: Boolean = false, // 是否启用翻译阅读模式
+
+        var openCredits: Int = 0,    // 音频片头(秒)
+        var closeCredits: Int = 0,   // 音频片尾(秒)
+        var playMode: Int = 0,       // 音频播放模式
+        var audioGain: Int = 0       // 音频增益(mB,-6000..6000)
 
     ) : Parcelable
 

+ 14 - 8
app/src/main/java/io/legado/app/di/appModule.kt

@@ -68,11 +68,6 @@ import io.legado.app.data.repository.LocalPasswordRepository
 import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.OtherConfigSystemRepository
 import io.legado.app.data.repository.OtherSettingsRepository
-import io.legado.app.data.repository.manga.DefaultMangaReaderSession
-import io.legado.app.data.repository.manga.MangaReaderActionRepository
-import io.legado.app.data.repository.manga.MangaReaderDataRepository
-import io.legado.app.domain.gateway.MangaReaderDataGateway
-import io.legado.app.domain.gateway.MangaReaderSessionFactory
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -101,6 +96,9 @@ import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
+import io.legado.app.data.repository.manga.DefaultMangaReaderSession
+import io.legado.app.data.repository.manga.MangaReaderActionRepository
+import io.legado.app.data.repository.manga.MangaReaderDataRepository
 import io.legado.app.data.security.CloudTtsCredentialCipher
 import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiChatGateway
@@ -147,6 +145,8 @@ import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.MangaReaderDataGateway
+import io.legado.app.domain.gateway.MangaReaderSessionFactory
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -225,6 +225,7 @@ import io.legado.app.ui.association.ImportHttpTtsViewModel
 import io.legado.app.ui.association.ImportReplaceRuleViewModel
 import io.legado.app.ui.association.ImportRssSourceViewModel
 import io.legado.app.ui.association.ImportTxtTocRuleViewModel
+import io.legado.app.ui.book.audio.AudioPlayCoordinator
 import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
@@ -311,13 +312,13 @@ import io.legado.app.ui.rss.subscription.RuleSubViewModel
 import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
 import io.legado.app.utils.isNightMode
 import io.legado.app.utils.sysConfiguration
-import org.koin.core.module.dsl.singleOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.datetime.Clock
 import org.koin.core.module.dsl.factoryOf
+import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
-import kotlinx.coroutines.Dispatchers
-import kotlinx.datetime.Clock
 
 val appModule = module {
 
@@ -628,9 +629,14 @@ val appModule = module {
     viewModel {
         AudioPlayViewModel(
             application = get(),
+            coordinator = get(),
             bookRepository = get(),
+            otherSettingsGateway = get(),
+            readAloudSettingsGateway = get(),
+            readSettingsGateway = get(),
         )
     }
+    singleOf(::AudioPlayCoordinator)
     viewModel {
         SourceLoginViewModel(
             application = get(),

+ 36 - 5
app/src/main/java/io/legado/app/model/AudioPlay.kt

@@ -34,14 +34,16 @@ import splitties.init.appCtx
 @Suppress("unused")
 object AudioPlay : CoroutineScope by MainScope() {
 
-    enum class PlayMode(val iconRes: Int) {
-        LIST_END_STOP(R.drawable.ic_play_mode_list_end_stop),
-        RANDOM(R.drawable.ic_exchange),
-        LIST_LOOP(R.drawable.ic_play_mode_list_loop);
+    enum class PlayMode {
+        LIST_END_STOP,
+        SINGLE_LOOP,
+        RANDOM,
+        LIST_LOOP;
 
         fun next(): PlayMode {
             return when (this) {
-                LIST_END_STOP -> RANDOM
+                LIST_END_STOP -> SINGLE_LOOP
+                SINGLE_LOOP -> RANDOM
                 RANDOM -> LIST_LOOP
                 LIST_LOOP -> LIST_END_STOP
             }
@@ -49,6 +51,7 @@ object AudioPlay : CoroutineScope by MainScope() {
     }
 
     var playMode = PlayMode.LIST_END_STOP
+    var speed = 1f  // 播放倍速(进程内记忆,AUDIO_SPEED 事件回写)
     var status = Status.STOP
     private var activityContext: Context? = null
     private var serviceContext: Context? = null
@@ -69,6 +72,8 @@ object AudioPlay : CoroutineScope by MainScope() {
 
     fun changePlayMode() {
         playMode = playMode.next()
+        book?.setPlayMode(playMode.ordinal)
+        saveRead()
         postEvent(EventBus.PLAY_MODE_CHANGED, playMode)
     }
 
@@ -104,6 +109,11 @@ object AudioPlay : CoroutineScope by MainScope() {
         durChapterPos = book.durChapterPos
         durPlayUrl = ""
         durAudioSize = 0
+        // 恢复本书记忆的播放模式
+        PlayMode.entries.getOrNull(book.getPlayMode())?.let {
+            playMode = it
+            postEvent(EventBus.PLAY_MODE_CHANGED, it)
+        }
         upDurChapter()
         SourceCallBack.callBackBook(SourceCallBack.START_READ, bookSource, book, durChapter)
         postEvent(EventBus.AUDIO_BUFFER_PROGRESS, 0)
@@ -268,6 +278,20 @@ object AudioPlay : CoroutineScope by MainScope() {
         }
     }
 
+    fun adjustGain(gain: Int) {
+        book?.let {
+            it.setAudioGain(gain)
+            // 增益需随书持久化,否则重启/重新拉取书籍后丢失
+            Coroutine.async { it.save() }
+        }
+        if (AudioPlayService.isRun) {
+            context.startService<AudioPlayService> {
+                action = IntentAction.adjustGain
+                putExtra("gain", gain)
+            }
+        }
+    }
+
     fun adjustProgress(position: Int) {
         durChapterPos = position
         saveRead()
@@ -318,6 +342,13 @@ object AudioPlay : CoroutineScope by MainScope() {
                 }
             }
 
+            PlayMode.SINGLE_LOOP -> {
+                durChapterPos = 0
+                durPlayUrl = ""
+                saveRead()
+                loadPlayUrl()
+            }
+
             PlayMode.RANDOM -> {
                 durChapterIndex = (0 until simulatedChapterSize).random()
                 durChapterPos = 0

+ 1 - 5
app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt

@@ -14,7 +14,6 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.service.AudioPlayService
 import io.legado.app.service.BaseReadAloudService
-import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.getPrefBoolean
@@ -95,10 +94,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
                     // break
                 }
 
-                MainActivity.hasActiveReadBookRoute ->
-                    postEvent(EventBus.MEDIA_BUTTON, true)
-
-                LifecycleHelp.isExistActivity(AudioPlayActivity::class.java) ->
+                MainActivity.hasActiveReadBookRoute || MainActivity.hasActiveAudioPlayRoute ->
                     postEvent(EventBus.MEDIA_BUTTON, true)
 
                 else -> if (AppConfig.mediaButtonOnExit || LifecycleHelp.activitySize() > 0 || !isMediaKey) {

+ 50 - 5
app/src/main/java/io/legado/app/service/AudioPlayService.kt

@@ -8,6 +8,7 @@ import android.content.IntentFilter
 import android.graphics.Bitmap
 import android.graphics.BitmapFactory
 import android.media.AudioManager
+import android.media.audiofx.LoudnessEnhancer
 import android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
 import android.os.Build
 import android.os.Bundle
@@ -32,7 +33,6 @@ import io.legado.app.constant.Status
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.exoplayer.ExoPlayerHelper
 import io.legado.app.help.glide.ImageLoader
@@ -40,7 +40,8 @@ import io.legado.app.model.AudioPlay
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.getMediaItem
 import io.legado.app.receiver.MediaButtonReceiver
-import io.legado.app.ui.book.audio.AudioPlayActivity
+import io.legado.app.ui.config.readConfig.ReadConfig
+import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.activityPendingIntent
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.printOnDebug
@@ -120,6 +121,7 @@ class AudioPlayService : BaseService(),
     private var upNotificationJob: Coroutine<*>? = null
     private var upPlayProgressJob: Job? = null
     private var playSpeed: Float = 1f
+    private var loudnessEnhancer: LoudnessEnhancer? = null
     private var cover: Bitmap =
         BitmapFactory.decodeResource(appCtx.resources, R.drawable.ic_launcher)!!
 
@@ -179,6 +181,7 @@ class AudioPlayService : BaseService(),
                 IntentAction.prev -> AudioPlay.prev()
                 IntentAction.next -> AudioPlay.next()
                 IntentAction.adjustSpeed -> upSpeed(intent.getFloatExtra("adjust", 1f))
+                IntentAction.adjustGain -> applyGain(intent.getIntExtra("gain", 0))
                 IntentAction.addTimer -> addTimer()
                 IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0))
                 IntentAction.adjustProgress -> {
@@ -200,6 +203,8 @@ class AudioPlayService : BaseService(),
         isRun = false
         abandonFocus()
         exoPlayer.release()
+        loudnessEnhancer?.release()
+        loudnessEnhancer = null
         mediaSessionCompat?.release()
         unregisterReceiver(broadcastReceiver)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED)
@@ -237,7 +242,10 @@ class AudioPlayService : BaseService(),
             )
             exoPlayer.setMediaItem(analyzeUrl.getMediaItem())
             exoPlayer.playWhenReady = true
-            exoPlayer.seekTo(position.toLong())
+            // 片头跳过设定(仅从头播放时生效)
+            val skipStartMs = (AudioPlay.book?.getOpenCredits() ?: 0) * 1000L
+            val playtime = if (position == 0) skipStartMs else position.toLong()
+            exoPlayer.seekTo(playtime)
             exoPlayer.prepare()
         }.onError {
             AppLog.put("播放出错\n${it.localizedMessage}", it)
@@ -339,6 +347,7 @@ class AudioPlayService : BaseService(),
             Player.STATE_READY -> {
                 // 准备好
                 AudioPlay.upLoading(false)
+                applyGain(AudioPlay.book?.getAudioGain() ?: 0)
                 if (exoPlayer.playWhenReady) {
                     AudioPlay.status = Status.PLAY
                     postEvent(EventBus.AUDIO_STATE, Status.PLAY)
@@ -428,19 +437,52 @@ class AudioPlayService : BaseService(),
      */
     private fun upPlayProgress() {
         upPlayProgressJob?.cancel()
+        val skipEnds = AudioPlay.book?.getCloseCredits() ?: 0
         upPlayProgressJob = lifecycleScope.launch {
             while (isActive) {
+                val durP = exoPlayer.currentPosition
                 //更新buffer位置
-                AudioPlay.playPositionChanged(exoPlayer.currentPosition.toInt())
+                AudioPlay.playPositionChanged(durP.toInt())
                 postEvent(EventBus.AUDIO_BUFFER_PROGRESS, exoPlayer.bufferedPosition.toInt())
                 postEvent(EventBus.AUDIO_PROGRESS, AudioPlay.durChapterPos)
                 postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration.toInt())
                 upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
+                // 片尾跳过检查
+                if (skipEnds > 0) {
+                    val duration = exoPlayer.duration
+                    if (duration > 0 && durP >= duration - (skipEnds * 1000L)) {
+                        upPlayProgressJob?.cancel()
+                        AudioPlay.playPositionChanged(duration.toInt())
+                        pause = true
+                        AudioPlay.next()
+                    }
+                }
                 delay(1000)
             }
         }
     }
 
+    /**
+     * 应用音频增益(LoudnessEnhancer,单位 mB,范围 -6000..6000)
+     */
+    private fun applyGain(gain: Int) {
+        try {
+            if (gain == 0) {
+                loudnessEnhancer?.release()
+                loudnessEnhancer = null
+                return
+            }
+            if (loudnessEnhancer == null) {
+                loudnessEnhancer = LoudnessEnhancer(exoPlayer.audioSessionId).apply {
+                    setEnabled(true)
+                }
+            }
+            loudnessEnhancer?.setTargetGain(gain.coerceIn(-6000, 6000))
+        } catch (e: Exception) {
+            AppLog.put("设置音频增益失败: ${e.localizedMessage}", e)
+        }
+    }
+
     /**
      * 更新媒体状态
      */
@@ -588,7 +630,10 @@ class AudioPlayService : BaseService(),
             .setContentTitle(nTitle)
             .setContentText(nSubtitle)
             .setContentIntent(
-                activityPendingIntent<AudioPlayActivity>("activity")
+                activityPendingIntent(
+                    MainActivity.createAudioPlayIntent(this@AudioPlayService),
+                    "activity",
+                )
             )
         builder.setLargeIcon(cover)
         builder.addAction(

+ 0 - 821
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt

@@ -1,821 +0,0 @@
-package io.legado.app.ui.book.audio
-
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.res.ColorStateList
-import android.graphics.Color
-import android.graphics.RenderEffect
-import android.graphics.Shader
-import android.graphics.Typeface
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.TransitionDrawable
-import android.icu.text.SimpleDateFormat
-import android.os.Build
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.activity.addCallback
-import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.scale
-import androidx.core.view.HapticFeedbackConstantsCompat
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import androidx.transition.TransitionManager
-import com.google.android.material.color.DynamicColors
-import com.google.android.material.color.DynamicColorsOptions
-import com.google.android.material.color.MaterialColors
-import com.google.android.material.slider.Slider
-import com.google.android.material.transition.platform.MaterialContainerTransform
-import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.BookType
-import io.legado.app.constant.EventBus
-import io.legado.app.constant.Status
-import io.legado.app.constant.Theme
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookChapter
-import io.legado.app.data.entities.BookSource
-import io.legado.app.databinding.ActivityAudioPlayBinding
-import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsGateway
-import io.legado.app.domain.model.PlaybackTimer
-import io.legado.app.help.book.isAudio
-import io.legado.app.help.book.removeType
-import io.legado.app.help.config.AppConfig
-import io.legado.app.model.AudioPlay
-import io.legado.app.model.BookCover
-import io.legado.app.model.SourceCallBack
-import io.legado.app.service.AudioPlayService
-import io.legado.app.ui.about.AppLogDialog
-import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
-import io.legado.app.ui.book.toc.TocActivityResult
-import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.ui.login.SourceLoginType
-import io.legado.app.ui.main.MainActivity
-import io.legado.app.utils.ToolbarUtils.setAllIconsColor
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.observeEvent
-import io.legado.app.utils.observeEventSticky
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivityForBook
-import io.legado.app.utils.startAnimation
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.koin.android.ext.android.inject
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import splitties.views.onLongClick
-import java.util.Locale
-
-/**
- * 音频播放
- */
-@SuppressLint("ObsoleteSdkInt")
-class AudioPlayActivity :
-    VMBaseActivity<ActivityAudioPlayBinding, AudioPlayViewModel>(toolBarTheme = Theme.Dark),
-    ChangeBookSourceDialog.CallBack,
-    AudioPlay.CallBack {
-
-    private val otherSettingsGateway by inject<OtherSettingsGateway>()
-    private val readAloudSettingsGateway by inject<ReadAloudSettingsGateway>()
-
-    override val binding by viewBinding(ActivityAudioPlayBinding::inflate)
-    override val viewModel by viewModel<AudioPlayViewModel>()
-    private var adjustProgress = false
-    private var playMode = AudioPlay.PlayMode.LIST_END_STOP
-    private var playSpeed = 1f
-    private var primaryFinalColor: Int = 0
-    private var surfaceFinalColor: Int = 0
-    private var secondaryFinalColor: Int = 0
-    private var onSurfaceFinalColor: Int = 0
-    private var secondaryContainerFinalColor: Int = 0
-    private var currentJob: Job? = null
-    private var wrappedContext: Context? = null
-    // private val lyricViewX by lazy { binding.lyricViewX }
-    private var lyricOn = false
-    private var oldLyric: String? = null
-
-    private val progressTimeFormat by lazy {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
-            SimpleDateFormat("mm:ss", Locale.getDefault())
-        } else {
-            java.text.SimpleDateFormat("mm:ss", Locale.getDefault())
-        }
-    }
-    private val tocActivityResult = registerForActivityResult(TocActivityResult()) {
-        it?.let {
-            if (it.first != AudioPlay.book?.durChapterIndex
-                || it.second == 0
-            ) {
-                AudioPlay.skipTo(it.first)
-            }
-        }
-    }
-    private val sourceEditResult =
-        registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()) {
-            if (it.resultCode == RESULT_OK) {
-                viewModel.upSource()
-            }
-        }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        val transform = MaterialContainerTransform().apply {
-            addTarget(binding.root)
-            scrimColor = Color.TRANSPARENT
-        }
-        window.sharedElementEnterTransition = transform
-        window.sharedElementReturnTransition = transform
-        super.onCreate(savedInstanceState)
-        binding.root.transitionName = intent.getStringExtra("transitionName")
-        primaryFinalColor =
-            MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary, -1)
-        surfaceFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, -1)
-        secondaryFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondary, -1)
-        onSurfaceFinalColor =
-            MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, -1)
-        secondaryContainerFinalColor = MaterialColors.getColor(
-            this,
-            com.google.android.material.R.attr.colorSecondaryContainer,
-            -1
-        )
-        setSupportActionBar(binding.toolBar)
-        binding.toolBar.setTitle(" ")
-        binding.titleBar.setBackgroundResource(R.color.transparent)
-        AudioPlay.register(this)
-        viewModel.titleData.observe(this) {
-            binding.tvTitle.text = it
-        }
-        viewModel.coverData.observe(this) {
-            upCover(it)
-        }
-        viewModel.initData(intent)
-        initView()
-        onBackPressedDispatcher.addCallback(this){
-            if (savedInstanceState != null || !AudioPlay.inBookshelf) {
-                supportFinishAfterTransition()
-            } else {
-                callBackBookEnd()
-                supportFinishAfterTransition()
-            }
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.audio_play, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
-        menu.findItem(R.id.menu_login)?.isVisible = !AudioPlay.bookSource?.loginUrl.isNullOrBlank()
-        menu.findItem(R.id.menu_wake_lock)?.isChecked = AppConfig.audioPlayUseWakeLock
-        menu.findItem(R.id.menu_media_control)?.isChecked = AppConfig.systemMediaControlCompatibilityChange
-        return super.onMenuOpened(featureId, menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_change_source -> AudioPlay.book?.let {
-                showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
-            }
-
-            R.id.menu_login -> AudioPlay.bookSource?.let {
-                startActivity(
-                    MainActivity.createSourceLoginIntent(
-                        this,
-                        SourceLoginType.BookSource,
-                        it.bookSourceUrl
-                    )
-                )
-            }
-            R.id.menu_media_control -> lifecycleScope.launch {
-                readAloudSettingsGateway.update {
-                    it.copy(
-                        systemMediaControlCompatibilityChange =
-                            !it.systemMediaControlCompatibilityChange
-                    )
-                }
-            }
-            R.id.menu_wake_lock -> lifecycleScope.launch {
-                otherSettingsGateway.update {
-                    it.copy(audioPlayUseWakeLock = !it.audioPlayUseWakeLock)
-                }
-            }
-            R.id.menu_copy_audio_url -> {
-                AudioPlay.book?.let {
-                    SourceCallBack.callBackBtn(
-                        this,
-                        SourceCallBack.CLICK_COPY_PLAY_URL,
-                        AudioPlay.bookSource,
-                        it,
-                        AudioPlay.durChapter,
-                        BookType.audio
-                    ) {
-                        sendToClip(AudioPlayService.url)
-                    }
-                }
-            }
-            R.id.menu_edit_source -> AudioPlay.bookSource?.let {
-                sourceEditResult.launch(
-                    MainActivity.createBookSourceEditIntent(this, it.bookSourceUrl)
-                )
-            }
-
-            R.id.menu_log -> showDialogFragment<AppLogDialog>()
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    private fun callBackBookEnd() {
-        SourceCallBack.callBackBook(
-            SourceCallBack.END_READ,
-            AudioPlay.bookSource,
-            AudioPlay.book,
-            AudioPlay.durChapter
-        )
-    }
-
-    private fun initView() {
-        // 应用深度个性化中设置的字体
-        ThemeConfig.appFontPath?.let {
-            try {
-                val typeface = Typeface.createFromFile(it)
-                // 为所有文本控件设置字体
-                binding.tvTitle.typeface = typeface
-                binding.tvSubTitle.typeface = typeface
-                binding.tvDurTime.typeface = typeface
-                binding.tvAllTime.typeface = typeface
-                binding.tvSpeed.typeface = typeface
-                binding.tvTimer.typeface = typeface
-            } catch (e: Exception) {
-                e.printStackTrace()
-            }
-        }
-        binding.ivTimer.isEnabled = false
-        binding.ivFastForward.isEnabled = false
-
-        binding.ivPlayMode.setOnClickListener {
-            AudioPlay.changePlayMode()
-        }
-
-        observeEventSticky<AudioPlay.PlayMode>(EventBus.PLAY_MODE_CHANGED) {
-            playMode = it
-            updatePlayModeIcon()
-        }
-        binding.fabPlayStop.setOnClickListener {
-            playButton()
-        }
-        binding.fabPlayStop.onLongClick {
-            AudioPlay.stop()
-        }
-        binding.ivSkipNext.setOnClickListener {
-            AudioPlay.next()
-        }
-        binding.ivSkipPrevious.setOnClickListener {
-            AudioPlay.prev()
-        }
-
-//        binding.playerProgress.setOnSeekBarChangeListener(object : SeekBarChangeListener {
-//            override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
-//                binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
-//            }
-//
-//            override fun onStartTrackingTouch(seekBar: SeekBar) {
-//                adjustProgress = true
-//            }
-//
-//            override fun onStopTrackingTouch(seekBar: SeekBar) {
-//                adjustProgress = false
-//                AudioPlay.adjustProgress(seekBar.progress)
-//            }
-//        })
-
-        binding.playerProgress.addOnChangeListener { _, value, fromUser ->
-            if (fromUser) {
-                binding.tvDurTime.text = progressTimeFormat.format(value.toLong())
-            }
-        }
-
-        binding.playerProgress.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
-            override fun onStartTrackingTouch(slider: Slider) {
-                adjustProgress = true
-            }
-
-            override fun onStopTrackingTouch(slider: Slider) {
-                adjustProgress = false
-                val progress = slider.value.toInt()
-                AudioPlay.adjustProgress(progress)
-                // 更新歌词进度
-                upLyricP(progress)
-            }
-        })
-
-        binding.playerProgress.setLabelFormatter { value ->
-            progressTimeFormat.format(value.toLong())
-        }
-
-        binding.ivChapter.setOnClickListener {
-            AudioPlay.book?.let {
-                tocActivityResult.launch(it.bookUrl)
-            }
-        }
-
-        binding.ivFastForward.setOnClickListener { toggleFastForward() }
-        binding.ivTimer.setOnClickListener { toggleTimer() }
-
-        binding.llSubMenu.applyNavigationBarPadding()
-    }
-
-
-    private fun toggleFastForward() {
-        TransitionManager.beginDelayedTransition(binding.root)
-        if (binding.llSet.isVisible) {
-            if (binding.ivTimer.isChecked) {
-                initSlider(true)
-            } else {
-                binding.llSet.gone()
-            }
-            binding.ivTimer.isChecked = false
-        } else {
-            initSlider(true)
-            binding.llSet.visible()
-        }
-    }
-
-    private fun toggleTimer() {
-        TransitionManager.beginDelayedTransition(binding.root)
-        if (binding.llSet.isVisible) {
-            if (binding.ivFastForward.isChecked) {
-                initSlider(false)
-            } else {
-                binding.llSet.gone()
-            }
-            binding.ivFastForward.isChecked = false
-        } else {
-            initSlider(false)
-            binding.llSet.visible()
-        }
-    }
-
-    private fun initSlider(isAudioPlay: Boolean) {
-        binding.settingSlider.clearOnChangeListeners()
-        binding.settingSlider.clearOnSliderTouchListeners()
-        binding.btnReset.clearOnCheckedChangeListeners()
-
-        if (isAudioPlay) {
-            binding.settingSlider.isEnabled = AudioPlay.status != Status.STOP
-            binding.btnReset.setOnClickListener {
-                binding.settingSlider.value = 1f
-                AudioPlay.adjustSpeed(1f)
-            }
-            binding.settingSlider.apply {
-                valueFrom = 0.5f
-                valueTo = 5.0f
-                stepSize = 0.1f
-                value = playSpeed
-
-                addOnChangeListener { _, newValue, fromUser ->
-                    if (fromUser) {
-                        AudioPlay.adjustSpeed(newValue)
-                    }
-                }
-
-                addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
-                    override fun onStartTrackingTouch(slider: Slider) {
-                        slider.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
-                    }
-
-                    override fun onStopTrackingTouch(slider: Slider) {}
-                })
-
-                setLabelFormatter { value -> String.format(Locale.ROOT, "%.1fX", value) }
-            }
-        } else {
-            binding.settingSlider.isEnabled = true
-            binding.btnReset.setOnClickListener {
-                binding.settingSlider.value = 0f
-                AudioPlay.setTimer(0)
-            }
-            binding.settingSlider.apply {
-                valueFrom = PlaybackTimer.MIN_MINUTES.toFloat()
-                valueTo = PlaybackTimer.MAX_MINUTES.toFloat()
-                stepSize = 1f
-                value = PlaybackTimer.normalize(AudioPlayService.timeMinute).toFloat()
-
-                addOnChangeListener { _, newValue, fromUser ->
-                    if (fromUser) AudioPlay.setTimer(newValue.toInt())
-                }
-
-                addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
-                    override fun onStartTrackingTouch(slider: Slider) {
-                        slider.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
-                    }
-
-                    override fun onStopTrackingTouch(slider: Slider) {}
-                })
-
-                setLabelFormatter { v -> "${v.toInt()}m" }
-            }
-        }
-    }
-
-    private fun updatePlayModeIcon() {
-        binding.ivPlayMode.setIconResource(playMode.iconRes)
-    }
-
-    private fun upCover(path: String?) {
-        BookCover.load(
-            this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl,
-            onLoadFinish = {
-                binding.ivCover.post {
-                    val drawable = binding.ivCover.drawable
-                    if (drawable != null) {
-                        addColorScheme(drawable)
-                    }
-                }
-            }).into(binding.ivCover)
-        if (!AppConfig.isEInkMode) {
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                BookCover.load(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl)
-                    .into(binding.ivBg)
-                val blurEffect = RenderEffect.createBlurEffect(120f, 120f, Shader.TileMode.CLAMP)
-                binding.ivBg.setRenderEffect(blurEffect)
-            } else {
-                // 低版本直接加载模糊图
-                BookCover.loadBlur(this, path, sourceOrigin = AudioPlay.bookSource?.bookSourceUrl)
-                    .into(binding.ivBg)
-            }
-        }
-        addColorScheme(binding.ivCover.drawable)
-    }
-
-    private fun playButton() {
-        when (AudioPlay.status) {
-            Status.PLAY -> AudioPlay.pause(this)
-            Status.PAUSE -> AudioPlay.resume(this)
-            else -> AudioPlay.loadOrUpPlayUrl()
-        }
-    }
-
-    private fun addColorScheme(drawable: Drawable?) {
-        currentJob?.cancel()
-        currentJob = CoroutineScope(Dispatchers.Default).launch {
-            val bitmap = when (drawable) {
-                is BitmapDrawable -> drawable.bitmap
-                is TransitionDrawable -> (drawable.getDrawable(1) as? BitmapDrawable)?.bitmap
-                else -> null
-            } ?: return@launch
-
-            val colorAccuracy = true
-            val targetWidth = if (colorAccuracy) (bitmap.width / 4).coerceAtMost(256) else 16
-            val targetHeight = if (colorAccuracy) (bitmap.height / 4).coerceAtMost(256) else 16
-            val scaledBitmap = bitmap.scale(targetWidth, targetHeight, false)
-
-            val options = DynamicColorsOptions.Builder()
-                .setContentBasedSource(scaledBitmap)
-                .build()
-
-            wrappedContext = DynamicColors.wrapContextIfAvailable(
-                this@AudioPlayActivity,
-                options
-            ).apply {
-                resources.configuration.uiMode =
-                    this@AudioPlayActivity.resources.configuration.uiMode
-            }
-
-            withContext(Dispatchers.Main) {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-                    applyColorScheme()
-                }
-            }
-        }
-    }
-
-    private suspend fun applyColorScheme() {
-        val ctx = wrappedContext ?: this
-
-        val colorPrimary = MaterialColors.getColor(ctx, androidx.appcompat.R.attr.colorPrimary, -1)
-        val colorSecondary =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSecondary, -1)
-        val colorOnSurface =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, -1)
-        val colorSurface =
-            MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, -1)
-        val colorSurfaceContainer =
-            MaterialColors.getColor(
-                ctx,
-                com.google.android.material.R.attr.colorSurfaceContainer,
-                -1
-            )
-        val colorSecondaryContainer =
-            MaterialColors.getColor(
-                ctx,
-                com.google.android.material.R.attr.colorSecondaryContainer,
-                -1
-            )
-        val colorOnSurfaceVariant =
-            MaterialColors.getColor(
-                ctx,
-                com.google.android.material.R.attr.colorOnSurfaceVariant,
-                -1
-            )
-
-        val primaryTransition = ValueAnimator.ofArgb(primaryFinalColor, colorPrimary).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                binding.settingSlider.thumbTintList = ColorStateList.valueOf(color)
-                binding.settingSlider.trackActiveTintList = ColorStateList.valueOf(color)
-                binding.playerProgress.thumbTintList = ColorStateList.valueOf(color)
-                binding.playerProgress.trackActiveTintList = ColorStateList.valueOf(color)
-                binding.tvDurTime.setTextColor(color)
-                binding.tvAllTime.setTextColor(color)
-                // 更新歌词高亮颜色
-                // binding.lyricViewX.setCurrentColor(color)
-            }
-        }
-
-        val secondaryContainerTransition =
-            ValueAnimator.ofArgb(secondaryContainerFinalColor, colorSecondaryContainer).apply {
-                duration = 400L
-                addUpdateListener { animation ->
-                    val color = animation.animatedValue as Int
-                    binding.fabPlayStop.backgroundTintList = ColorStateList.valueOf(color)
-                    binding.btnReset.backgroundTintList = ColorStateList.valueOf(color)
-                    binding.settingSlider.trackInactiveTintList = ColorStateList.valueOf(color)
-                    binding.playerProgress.trackInactiveTintList = ColorStateList.valueOf(color)
-                }
-            }
-
-        val surfaceTransition =
-            ValueAnimator.ofArgb(surfaceFinalColor, colorSurfaceContainer).apply {
-                duration = 400L
-                addUpdateListener { animation ->
-                    val color = animation.animatedValue as Int
-                    binding.vwBg2.setBackgroundColor(color)
-                }
-            }
-
-        val textTransition = ValueAnimator.ofArgb(onSurfaceFinalColor, colorOnSurface).apply {
-            duration = 400L
-            addUpdateListener { animation ->
-                val color = animation.animatedValue as Int
-                binding.tvTitle.setTextColor(color)
-            }
-        }
-
-        withContext(Dispatchers.Main) {
-            surfaceTransition.start()
-            textTransition.start()
-            primaryTransition.start()
-            secondaryContainerTransition.start()
-        }
-
-        surfaceFinalColor = colorSurface
-        secondaryFinalColor = colorSecondary
-        onSurfaceFinalColor = colorOnSurface
-        primaryFinalColor = colorPrimary
-        secondaryContainerFinalColor = colorSecondaryContainer
-
-        binding.toolBar.setAllIconsColor(colorOnSurface)
-        binding.progressLoading.setIndicatorColor(colorPrimary)
-        binding.settingSlider.tickActiveTintList = ColorStateList.valueOf(colorSurface)
-        binding.settingSlider.tickInactiveTintList = ColorStateList.valueOf(colorPrimary)
-
-        binding.tvSubTitle.setTextColor(colorOnSurfaceVariant)
-        binding.btnTimer.imageTintList = ColorStateList.valueOf(colorOnSurface)
-        binding.tvSpeed.setTextColor(colorOnSurface)
-        binding.tvTimer.setTextColor(colorOnSurface)
-
-        binding.cdTimer.setCardBackgroundColor(colorSurfaceContainer)
-        binding.cdSpeed.setCardBackgroundColor(colorSurfaceContainer)
-
-        val states = arrayOf(
-            intArrayOf(android.R.attr.state_enabled, android.R.attr.state_checked),
-            intArrayOf(android.R.attr.state_enabled, -android.R.attr.state_checked),
-            intArrayOf(-android.R.attr.state_enabled)
-        )
-
-        val colors = intArrayOf(
-            colorSurface,
-            colorOnSurface,
-            colorOnSurface.copy(alpha = 0.3f)
-        )
-
-        val stateList = ColorStateList(states, colors)
-        listOf(
-            binding.ivSkipNext, binding.ivSkipPrevious, binding.ivPlayMode, binding.ivTimer,
-            binding.ivChapter, binding.ivFastForward, binding.btnReset, binding.fabPlayStop
-        ).forEach { btn ->
-            btn.setTextColor(stateList)
-            btn.iconTint = stateList
-        }
-    }
-
-    override val oldBook: Book?
-        get() = AudioPlay.book
-
-    override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
-        if (book.isAudio) {
-            viewModel.changeTo(source, book, toc)
-        } else {
-            AudioPlay.stop()
-            lifecycleScope.launch {
-                withContext(IO) {
-                    AudioPlay.book?.migrateTo(
-                        book,
-                        toc,
-                        AppConfig.replaceEnableDefault,
-                        AppConfig.chineseConverterType,
-                    )
-                    book.removeType(BookType.updateError)
-                    AudioPlay.book?.delete()
-                    appDb.bookDao.insert(book)
-                }
-                startActivityForBook(book)
-                finish()
-            }
-        }
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        if (AudioPlay.status != Status.PLAY) {
-            AudioPlay.stop()
-        }
-        AudioPlay.unregister(this)
-    }
-
-    @SuppressLint("SetTextI18n")
-    override fun observeLiveBus() {
-        observeEvent<Boolean>(EventBus.MEDIA_BUTTON) {
-            if (it) {
-                playButton()
-            }
-        }
-        observeEventSticky<Int>(EventBus.AUDIO_STATE) { state ->
-            AudioPlay.status = state
-
-            val iconDrawable = if (state == Status.PLAY) {
-                binding.ivTimer.isEnabled = true
-                binding.ivFastForward.isEnabled = true
-                AppCompatResources.getDrawable(this, R.drawable.play_anim)
-            } else {
-                AppCompatResources.getDrawable(this, R.drawable.pause_anim)
-            }
-
-            val bgDrawable = if (state == Status.PLAY) {
-                AppCompatResources.getDrawable(this, R.drawable.bg_play_anim)
-            } else {
-                AppCompatResources.getDrawable(this, R.drawable.bg_pause_anim)
-            }
-
-            binding.fabPlayStop.icon = iconDrawable
-            binding.fabPlayStop.background = bgDrawable
-
-            iconDrawable?.startAnimation()
-            bgDrawable?.startAnimation()
-        }
-
-        observeEventSticky<String>(EventBus.AUDIO_SUB_TITLE) {
-            binding.tvSubTitle.text = it
-            binding.ivSkipPrevious.isEnabled = AudioPlay.durChapterIndex > 0
-            binding.ivSkipNext.isEnabled =
-                AudioPlay.durChapterIndex < AudioPlay.simulatedChapterSize - 1
-        }
-//        observeEventSticky<Int>(EventBus.AUDIO_SIZE) {
-//            binding.playerProgress.max = it
-//            binding.tvAllTime.text = progressTimeFormat.format(it.toLong())
-//        }
-//        observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) {
-//            if (!adjustProgress) binding.playerProgress.progress = it
-//            binding.tvDurTime.text = progressTimeFormat.format(it.toLong())
-//        }
-//        observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
-//            binding.playerProgress.secondaryProgress = it
-//
-//        }
-        observeEventSticky<Int>(EventBus.AUDIO_SIZE) { size ->
-            binding.playerProgress.valueTo = maxOf(1f, size.toFloat())
-            binding.tvAllTime.text = progressTimeFormat.format(size.toLong())
-        }
-
-        observeEventSticky<Int>(EventBus.AUDIO_PROGRESS) { progress ->
-            val slider = binding.playerProgress
-            val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo)
-            if (!adjustProgress) slider.value = safeValue
-            binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
-            // 更新歌词进度
-            upLyricP(progress)
-        }
-
-        observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
-            //updateBufferProgress(it)
-        }
-
-        observeEventSticky<Float>(EventBus.AUDIO_SPEED) {
-            TransitionManager.beginDelayedTransition(binding.root)
-            playSpeed = it
-            binding.tvSpeed.text = String.format(Locale.ROOT, "%.1fX", it)
-            if (it != 1f) {
-                binding.cdSpeed.visible()
-            } else
-                binding.cdSpeed.gone()
-        }
-        observeEventSticky<Int>(EventBus.AUDIO_DS) {
-            TransitionManager.beginDelayedTransition(binding.root)
-            binding.tvTimer.text = "${it}m"
-            if (it > 0) {
-                binding.cdTimer.visible()
-            } else
-                binding.cdTimer.gone()
-        }
-    }
-
-    override fun upLoading(loading: Boolean) {
-        runOnUiThread {
-            binding.progressLoading.visible(loading)
-        }
-    }
-
-    override fun upLyric(lyric: String?) {
-        /*
-        if (oldLyric == lyric) return
-        oldLyric = lyric
-        if (lyric.isNullOrBlank()) {
-            // 没有歌词时,显示标题区域,隐藏歌词
-            binding.lyricViewX.gone()
-            binding.llTitle.visible()
-            return
-        }
-        // 有歌词时,隐藏标题区域,显示歌词
-        binding.llTitle.gone()
-        // 先隐藏歌词视图,避免显示旧歌词
-        binding.lyricViewX.gone()
-        lyricViewX.loadLyric(lyric)
-        binding.lyricViewX.visible()
-        if (lyricOn) {
-            upLyricP(AudioPlay.durChapterPos)
-        } else {
-            lyricOn = true
-            lyricViewX.apply {
-                setNormalTextSize(50F)
-                setCurrentTextSize(60F)
-                // 加载深度个性化中设置的字体
-                ThemeConfig.appFontPath?.let {
-                    try {
-                        val typeface = Typeface.createFromFile(it)
-                        // LyricViewX 1.3.2 版本可能不支持 setTypeface 方法
-                        // 暂时注释掉,后续可以通过 XML 或其他方式设置
-                        // setTypeface(typeface)
-                    } catch (e: Exception) {
-                        e.printStackTrace()
-                    }
-                }
-                setDraggable(true, object : OnPlayClickListener {
-                    override fun onPlayClick(time: Long): Boolean {
-                        AudioPlay.adjustProgress(time.toInt())
-                        return true
-                    }
-                })
-            }
-            lyricViewX.postDelayed({
-                upLyricP(AudioPlay.durChapterPos)
-            }, 100)
-        }
-        */
-    }
-
-    override fun upLyricP(position: Int) {
-        /*
-        lyricViewX.updateTime(position.toLong(), false)
-        */
-    }
-
-    override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
-        viewModel.addToBookshelf(book, toc) {
-            toastOnUi("已添加到书架")
-        }
-    }
-
-}
-
-private fun Int.copy(alpha: Float): Int {
-    val alpha = (alpha * 255).toInt()
-    return (this and 0x00FFFFFF) or (alpha shl 24)
-}

+ 85 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayContract.kt

@@ -0,0 +1,85 @@
+package io.legado.app.ui.book.audio
+
+import androidx.compose.runtime.Stable
+import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.constant.Status
+import io.legado.app.model.AudioPlay
+import io.legado.app.ui.widget.components.player.PlayerChapterUi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class AudioPlayUiState(
+    val bookUrl: String = "",
+    val bookName: String = "",
+    val author: String = "",
+    val coverPath: String? = null,
+    val sourceOrigin: String? = null,
+    val chapterIndex: Int = 0,
+    val chapterTitle: String = "",
+    val chapters: ImmutableList<PlayerChapterUi> = persistentListOf(),
+    val status: Int = Status.STOP,
+    val isLoading: Boolean = false,
+    val position: Int = 0,
+    val duration: Int = 0,
+    val speed: Float = 1f,
+    val timerMinutes: Int = 0,
+    val playMode: AudioPlay.PlayMode = AudioPlay.PlayMode.LIST_END_STOP,
+    val bgMode: Int = ReadAloudBgMode.Blur,
+    val canLogin: Boolean = false,
+    val wakeLockEnabled: Boolean = false,
+    val mediaControlEnabled: Boolean = false,
+    val canPrevious: Boolean = false,
+    val canNext: Boolean = false,
+    val openCredits: Int = 0,
+    val closeCredits: Int = 0,
+    val audioGain: Int = 0,
+    val activeSheet: AudioPlaySheet? = null,
+) {
+    val isPlaying: Boolean get() = status == Status.PLAY
+}
+
+/** 播放器内的设置弹窗(由 UiState 管理,避免分散在 Screen 本地状态)。 */
+sealed interface AudioPlaySheet {
+    data object SkipCredits : AudioPlaySheet
+    data object Gain : AudioPlaySheet
+    data object Log : AudioPlaySheet
+}
+
+sealed interface AudioPlayIntent {
+    data class Init(val bookUrl: String, val inBookshelf: Boolean) : AudioPlayIntent
+    data object Refresh : AudioPlayIntent
+    data object TogglePlay : AudioPlayIntent
+    data object Stop : AudioPlayIntent
+    data object PreviousChapter : AudioPlayIntent
+    data object NextChapter : AudioPlayIntent
+    data class SelectChapter(val index: Int) : AudioPlayIntent
+    data class SeekTo(val positionMs: Int) : AudioPlayIntent
+    data object ChangePlayMode : AudioPlayIntent
+    data class SetSpeed(val value: Float) : AudioPlayIntent
+    data class SetTimer(val minutes: Int) : AudioPlayIntent
+    data class SetOpenCredits(val seconds: Int) : AudioPlayIntent
+    data class SetCloseCredits(val seconds: Int) : AudioPlayIntent
+    data class SetAudioGain(val gainMb: Int) : AudioPlayIntent
+    data object CycleBgMode : AudioPlayIntent
+    data class OpenSheet(val sheet: AudioPlaySheet) : AudioPlayIntent
+    data object DismissSheet : AudioPlayIntent
+    data object ChangeSource : AudioPlayIntent
+    data object Login : AudioPlayIntent
+    data object CopyPlayUrl : AudioPlayIntent
+    data object EditSource : AudioPlayIntent
+    data object ToggleWakeLock : AudioPlayIntent
+    data object ToggleMediaControl : AudioPlayIntent
+    data object SourceEdited : AudioPlayIntent
+    data object BackPressed : AudioPlayIntent
+}
+
+sealed interface AudioPlayEffect {
+    data object Finish : AudioPlayEffect
+    data class ShowToast(val message: String) : AudioPlayEffect
+    data class OpenChangeSource(val bookName: String, val author: String) : AudioPlayEffect
+    data class OpenLogin(val sourceUrl: String) : AudioPlayEffect
+    data object CopyPlayUrl : AudioPlayEffect
+    data class OpenEditSource(val sourceUrl: String) : AudioPlayEffect
+    data class OpenBookReader(val bookUrl: String) : AudioPlayEffect
+}

+ 290 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayCoordinator.kt

@@ -0,0 +1,290 @@
+package io.legado.app.ui.book.audio
+
+import android.app.Application
+import androidx.lifecycle.Observer
+import com.jeremyliao.liveeventbus.LiveEventBus
+import io.legado.app.constant.EventBus
+import io.legado.app.constant.Status
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.help.book.getBookSource
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.AudioPlay
+import io.legado.app.service.AudioPlayService
+import io.legado.app.ui.widget.components.player.PlayerChapterUi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+
+/**
+ * 有声书播放器与旧版 [AudioPlay] 模型 / [AudioPlayService] 之间的兼容边界:
+ * 监听 LiveEventBus 播放事件驱动重新快照,对外暴露稳定的状态流与播放动作。
+ */
+class AudioPlayCoordinator(
+    private val application: Application,
+    private val bookRepository: BookRepository,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+) {
+    private val refreshRequests = MutableSharedFlow<Unit>(replay = 1)
+    private val loading = MutableStateFlow(false)
+
+    private val liveEvents = callbackFlow {
+        val observer = Observer<Any> { value ->
+            // AUDIO_SPEED 事件回写最新倍速(服务不持久化倍速,由模型进程内记忆)
+            if (value is Float) AudioPlay.speed = value
+            trySend(Unit)
+        }
+        EVENT_KEYS.forEach { LiveEventBus.get<Any>(it).observeForever(observer) }
+        trySend(Unit)
+        awaitClose {
+            EVENT_KEYS.forEach { LiveEventBus.get<Any>(it).removeObserver(observer) }
+        }
+    }
+    private val bookState = merge(liveEvents, refreshRequests).map { snapshotBook() }
+
+    /**
+     * 章节列表:仅当书籍切换(bookUrl 变化)或目录数据变化时才重建,
+     * 避免每次播放事件(每秒一次的 AUDIO_PROGRESS 等)都重新订阅 Room 流、
+     * 重建列表实例,导致目录页 LaunchedEffect 反复触发滚动。
+     */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val chapters: Flow<ImmutableList<PlayerChapterUi>> =
+        bookState.map { it.bookUrl }
+            .distinctUntilChanged()
+            .flatMapLatest { bookUrl ->
+                if (bookUrl.isBlank()) {
+                    flowOf(persistentListOf())
+                } else {
+                    bookRepository.flowChapters(bookUrl).map { chapterList ->
+                        chapterList.map { chapter ->
+                            PlayerChapterUi(
+                                index = chapter.index,
+                                title = chapter.title,
+                                isVolume = chapter.isVolume,
+                                tocLevel = chapter.tocLevel,
+                            )
+                        }.toImmutableList()
+                    }
+                }
+            }
+
+    val state: Flow<AudioPlaySourceState> = combine(
+        bookState,
+        chapters,
+        loading,
+        otherSettingsGateway.settings,
+        readAloudSettingsGateway.settings,
+    ) { book, chapterList, isLoading, other, aloud ->
+        AudioPlaySourceState(
+            bookUrl = book.bookUrl,
+            bookName = book.bookName,
+            author = book.author,
+            coverPath = book.coverPath,
+            sourceOrigin = book.sourceOrigin,
+            chapterIndex = book.chapterIndex,
+            chapterTitle = book.chapterTitle,
+            chapters = chapterList,
+            status = book.status,
+            isLoading = isLoading,
+            position = book.position,
+            duration = book.duration,
+            speed = AudioPlay.speed,
+            timerMinutes = AudioPlayService.timeMinute,
+            playMode = AudioPlay.playMode,
+            canLogin = book.canLogin,
+            wakeLockEnabled = other.audioPlayUseWakeLock,
+            mediaControlEnabled = aloud.systemMediaControlCompatibilityChange,
+            canPrevious = book.canPrevious,
+            canNext = book.canNext,
+            openCredits = book.openCredits,
+            closeCredits = book.closeCredits,
+            audioGain = book.audioGain,
+        )
+    }
+
+    fun snapshot(): AudioPlaySourceState {
+        val book = snapshotBook()
+        return AudioPlaySourceState(
+            bookUrl = book.bookUrl,
+            bookName = book.bookName,
+            author = book.author,
+            coverPath = book.coverPath,
+            sourceOrigin = book.sourceOrigin,
+            chapterIndex = book.chapterIndex,
+            chapterTitle = book.chapterTitle,
+            chapters = persistentListOf(),
+            status = book.status,
+            isLoading = loading.value,
+            position = book.position,
+            duration = book.duration,
+            speed = AudioPlay.speed,
+            timerMinutes = AudioPlayService.timeMinute,
+            playMode = AudioPlay.playMode,
+            canLogin = book.canLogin,
+            wakeLockEnabled = otherSettingsGateway.currentSettings.audioPlayUseWakeLock,
+            mediaControlEnabled =
+                readAloudSettingsGateway.currentSettings.systemMediaControlCompatibilityChange,
+            canPrevious = book.canPrevious,
+            canNext = book.canNext,
+            openCredits = book.openCredits,
+            closeCredits = book.closeCredits,
+            audioGain = book.audioGain,
+        )
+    }
+
+    fun refresh() {
+        refreshRequests.tryEmit(Unit)
+    }
+
+    fun setLoading(value: Boolean) {
+        loading.value = value
+    }
+
+    fun togglePlay() {
+        when {
+            AudioPlay.status == Status.PLAY -> AudioPlay.pause(application)
+            AudioPlay.status == Status.PAUSE -> AudioPlay.resume(application)
+            else -> AudioPlay.loadOrUpPlayUrl()
+        }
+    }
+
+    fun stop() = AudioPlay.stop()
+
+    fun previous() = AudioPlay.prev()
+
+    fun next() = AudioPlay.next()
+
+    fun skipTo(index: Int) = AudioPlay.skipTo(index)
+
+    fun seekTo(positionMs: Int) = AudioPlay.adjustProgress(positionMs)
+
+    fun changePlayMode() = AudioPlay.changePlayMode()
+
+    fun setSpeed(value: Float) {
+        AudioPlay.speed = value
+        AudioPlay.adjustSpeed(value)
+    }
+
+    fun setTimer(minutes: Int) = AudioPlay.setTimer(minutes)
+
+    fun setOpenCredits(seconds: Int) {
+        AudioPlay.book?.let {
+            it.setOpenCredits(seconds.coerceIn(0, 180))
+            // save() 内含同步 DAO 调用,放到后台线程执行,避免主线程写库
+            Coroutine.async { it.save() }
+        }
+        refresh()
+    }
+
+    fun setCloseCredits(seconds: Int) {
+        AudioPlay.book?.let {
+            it.setCloseCredits(seconds.coerceIn(0, 180))
+            Coroutine.async { it.save() }
+        }
+        refresh()
+    }
+
+    fun setAudioGain(gainMb: Int) {
+        AudioPlay.adjustGain(gainMb)
+        refresh()
+    }
+
+    fun refreshBookSource() {
+        AudioPlay.book?.let { AudioPlay.bookSource = it.getBookSource() }
+        refresh()
+    }
+
+    private fun snapshotBook(): AudioPlayBookState {
+        val book = AudioPlay.book
+        val chapter = AudioPlay.durChapter
+        return AudioPlayBookState(
+            bookUrl = book?.bookUrl.orEmpty(),
+            bookName = book?.name.orEmpty(),
+            author = book?.author.orEmpty(),
+            coverPath = book?.getDisplayCover(),
+            sourceOrigin = book?.origin,
+            chapterIndex = AudioPlay.durChapterIndex,
+            chapterTitle = chapter?.title.orEmpty(),
+            status = AudioPlay.status,
+            position = AudioPlay.durChapterPos,
+            duration = AudioPlay.durAudioSize,
+            canLogin = !AudioPlay.bookSource?.loginUrl.isNullOrBlank(),
+            canPrevious = AudioPlay.durChapterIndex > 0,
+            canNext = AudioPlay.durChapterIndex < AudioPlay.simulatedChapterSize - 1,
+            openCredits = book?.getOpenCredits() ?: 0,
+            closeCredits = book?.getCloseCredits() ?: 0,
+            audioGain = book?.getAudioGain() ?: 0,
+        )
+    }
+
+    private companion object {
+        val EVENT_KEYS = listOf(
+            EventBus.AUDIO_STATE,
+            EventBus.AUDIO_SUB_TITLE,
+            EventBus.AUDIO_SIZE,
+            EventBus.AUDIO_PROGRESS,
+            EventBus.AUDIO_SPEED,
+            EventBus.AUDIO_DS,
+            EventBus.PLAY_MODE_CHANGED,
+        )
+    }
+}
+
+data class AudioPlaySourceState(
+    val bookUrl: String,
+    val bookName: String,
+    val author: String,
+    val coverPath: String?,
+    val sourceOrigin: String?,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapters: ImmutableList<PlayerChapterUi>,
+    val status: Int,
+    val isLoading: Boolean,
+    val position: Int,
+    val duration: Int,
+    val speed: Float,
+    val timerMinutes: Int,
+    val playMode: AudioPlay.PlayMode,
+    val canLogin: Boolean,
+    val wakeLockEnabled: Boolean,
+    val mediaControlEnabled: Boolean,
+    val canPrevious: Boolean,
+    val canNext: Boolean,
+    val openCredits: Int,
+    val closeCredits: Int,
+    val audioGain: Int,
+)
+
+private data class AudioPlayBookState(
+    val bookUrl: String,
+    val bookName: String,
+    val author: String,
+    val coverPath: String?,
+    val sourceOrigin: String?,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val status: Int,
+    val position: Int,
+    val duration: Int,
+    val canLogin: Boolean,
+    val canPrevious: Boolean,
+    val canNext: Boolean,
+    val openCredits: Int,
+    val closeCredits: Int,
+    val audioGain: Int,
+)

+ 723 - 0
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayScreen.kt

@@ -0,0 +1,723 @@
+package io.legado.app.ui.book.audio
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Login
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Repeat
+import androidx.compose.material.icons.filled.RepeatOne
+import androidx.compose.material.icons.filled.Shuffle
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.SwapHoriz
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.filled.WbTwilight
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.haze.HazeProgressive
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import io.legado.app.R
+import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.constant.Status
+import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.model.AudioPlay
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.hazeStyle.HazeLegado
+import io.legado.app.ui.util.rememberBlurBackdrop
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallAnimatedButton
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.image.cover.BookCoverImage
+import io.legado.app.ui.widget.components.log.AppLogSheet
+import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
+import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
+import io.legado.app.ui.widget.components.player.PlayerBackground
+import io.legado.app.ui.widget.components.player.PlayerProgressSlider
+import io.legado.app.ui.widget.components.player.PlayerTocPage
+import io.legado.app.ui.widget.components.player.playerBgModeLabel
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.coroutines.launch
+import top.yukonga.miuix.kmp.blur.layerBackdrop
+import java.util.Locale
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalHazeMaterialsApi::class)
+@Composable
+fun AudioPlayScreenContent(
+    state: AudioPlayUiState,
+    onIntent: (AudioPlayIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
+        state = verticalPagerState,
+        orientation = Orientation.Vertical,
+    )
+    val coroutineScope = rememberCoroutineScope()
+    var activeAdjustment by remember { mutableStateOf<AudioAdjustment?>(null) }
+    var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed) }
+    var timerPreview by remember(state.timerMinutes) {
+        mutableFloatStateOf(state.timerMinutes.toFloat())
+    }
+    var menuExpanded by remember { mutableStateOf(false) }
+    val pagerHazeState = remember { HazeState() }
+    val hazeEnabled =
+        state.bgMode != ReadAloudBgMode.Solid && state.bgMode != ReadAloudBgMode.Transparent
+    val textBackdrop = rememberBlurBackdrop()
+    val flowingLightActive = state.bgMode == ReadAloudBgMode.FlowingLight
+    val overlayHazeStyle = HazeLegado.ultraThinPlus(
+        containerColor = LegadoTheme.colorScheme.surface,
+    )
+    val pageContentPadding = PaddingValues(
+        top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
+        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + when (
+            activeAdjustment
+        ) {
+            null -> 216.dp
+            AudioAdjustment.Speed -> 264.dp
+            AudioAdjustment.Timer -> 344.dp
+        },
+    )
+    AppScaffold(
+        modifier = Modifier.fillMaxSize(),
+        alwaysDrawBehindBars = true,
+        disableHazeSource = true,
+        contentWindowInsets = WindowInsets(0),
+        topBar = {
+            val hazeModifier = if (hazeEnabled) {
+                Modifier.hazeEffect(state = pagerHazeState, style = overlayHazeStyle) {
+                    progressive = HazeProgressive.verticalGradient(
+                        startIntensity = 1f,
+                        endIntensity = 0f,
+                    )
+                }
+            } else {
+                Modifier
+            }
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clip(RectangleShape)
+                    .then(hazeModifier)
+                    .pointerInput(Unit) { detectTapGestures(onTap = {}) }
+                    .windowInsetsPadding(WindowInsets.statusBars),
+            ) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 16.dp, vertical = 8.dp),
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.SpaceBetween
+                ) {
+                    MediumTonalButton(
+                        onClick = onBack,
+                        icon = AppIcons.Back,
+                        contentDescription = stringResource(R.string.back),
+                    )
+
+                    Box {
+                        MediumTonalButton(
+                            onClick = { menuExpanded = true },
+                            icon = AppIcons.MoreVert,
+                            contentDescription = stringResource(R.string.more),
+                        )
+                        RoundDropdownMenu(
+                            expanded = menuExpanded,
+                            onDismissRequest = { menuExpanded = false },
+                        ) { dismiss ->
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.change_origin),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.SwapHoriz)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.ChangeSource)
+                                },
+                            )
+                            if (state.canLogin) {
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.login),
+                                    leadingIcon = {
+                                        MenuItemIcon(Icons.Default.Login)
+                                    },
+                                    onClick = {
+                                        dismiss()
+                                        onIntent(AudioPlayIntent.Login)
+                                    },
+                                )
+                            }
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.copy_play_url),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.ContentCopy)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.CopyPlayUrl)
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.edit_book_source),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.Edit)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.EditSource)
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.audio_play_skip_credits),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.FastForward)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.SkipCredits))
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.audio_play_gain),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.VolumeUp)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Gain))
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.audio_play_wake_lock),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.Lock)
+                                },
+                                isSelected = state.wakeLockEnabled,
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.ToggleWakeLock)
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.system_media_control_compatibility_change),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.Headphones)
+                                },
+                                isSelected = state.mediaControlEnabled,
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.ToggleMediaControl)
+                                },
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.log),
+                                leadingIcon = {
+                                    MenuItemIcon(Icons.Default.Info)
+                                },
+                                onClick = {
+                                    dismiss()
+                                    onIntent(AudioPlayIntent.OpenSheet(AudioPlaySheet.Log))
+                                },
+                            )
+                        }
+                    }
+                }
+            }
+        },
+        bottomBar = {
+            val hazeModifier = if (hazeEnabled) {
+                Modifier.hazeEffect(state = pagerHazeState, style = overlayHazeStyle) {
+                    progressive = HazeProgressive.verticalGradient(
+                        startIntensity = 0f,
+                        endIntensity = 1f,
+                    )
+                }
+            } else {
+                Modifier
+            }
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clip(RectangleShape)
+                    .then(hazeModifier)
+                    .pointerInput(Unit) { detectTapGestures(onTap = {}) }
+                    .windowInsetsPadding(WindowInsets.navigationBars)
+                    .padding(vertical = 8.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                PlayerProgressSlider(
+                    modifier = Modifier
+                        .padding(horizontal = 24.dp),
+                    value = state.position.coerceIn(0, state.duration).toFloat(),
+                    onValueChange = { onIntent(AudioPlayIntent.SeekTo(it.toInt())) },
+                    valueRange = 0f..state.duration.coerceAtLeast(1).toFloat(),
+                )
+
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(horizontal = 24.dp),
+                    horizontalArrangement = Arrangement.SpaceBetween
+                ) {
+                    AppText(
+                        formatAudioTime(state.position),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                    AppText(
+                        formatAudioTime(state.duration),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(top = 12.dp),
+                    horizontalArrangement = Arrangement.SpaceEvenly,
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    MediumPlainButton(
+                        onClick = { onIntent(AudioPlayIntent.PreviousChapter) },
+                        enabled = state.canPrevious,
+                        icon = Icons.Default.SkipPrevious,
+                        contentDescription = stringResource(R.string.previous_chapter),
+                    )
+                    Box(contentAlignment = Alignment.Center) {
+                        MediumTonalButton(
+                            onClick = { onIntent(AudioPlayIntent.TogglePlay) },
+                            onLongClick = { onIntent(AudioPlayIntent.Stop) },
+                            icon = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+                            contentDescription = stringResource(
+                                if (state.isPlaying) R.string.pause else R.string.audio_play
+                            ),
+                            modifier = Modifier.size(72.dp),
+                        )
+                        if (state.isLoading) {
+                            CircularProgressIndicator(
+                                modifier = Modifier.size(56.dp),
+                                strokeWidth = 3.dp,
+                                color = LegadoTheme.colorScheme.onSecondaryContainer,
+                            )
+                        }
+                    }
+                    MediumPlainButton(
+                        onClick = { onIntent(AudioPlayIntent.NextChapter) },
+                        enabled = state.canNext,
+                        icon = Icons.Default.SkipNext,
+                        contentDescription = stringResource(R.string.next_chapter),
+                    )
+                }
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 16.dp, horizontal = 24.dp),
+                    horizontalArrangement = Arrangement.SpaceEvenly,
+                ) {
+                    SmallAnimatedButton(
+                        containerColor = Color.Transparent,
+                        checked = false,
+                        icon = Icons.AutoMirrored.Filled.FormatListBulleted,
+                        iconChecked = Icons.Default.KeyboardArrowUp,
+                        text = stringResource(
+                            if (verticalPagerState.currentPage == 0) {
+                                R.string.chapter_list
+                            } else {
+                                R.string.back
+                            }
+                        ),
+                        contentDescription = stringResource(
+                            if (verticalPagerState.currentPage == 0) {
+                                R.string.chapter_list
+                            } else {
+                                R.string.back
+                            }
+                        ),
+                        onCheckedChange = {
+                            coroutineScope.launch {
+                                verticalPagerState.animateScrollToPage(
+                                    page = if (verticalPagerState.currentPage == 0) 1 else 0,
+                                    animationSpec = tween(
+                                        durationMillis = 520,
+                                        easing = FastOutSlowInEasing,
+                                    ),
+                                )
+                            }
+                        },
+                    )
+                    SmallAnimatedButton(
+                        containerColor = Color.Transparent,
+                        checked = false,
+                        icon = Icons.Default.WbTwilight,
+                        text = playerBgModeLabel(state.bgMode),
+                        contentDescription = playerBgModeLabel(state.bgMode),
+                        onCheckedChange = { onIntent(AudioPlayIntent.CycleBgMode) },
+                    )
+                    SmallAnimatedButton(
+                        containerColor = Color.Transparent,
+                        checked = false,
+                        icon = playModeIcon(state.playMode),
+                        text = playModeContentDescription(state.playMode),
+                        contentDescription = playModeContentDescription(state.playMode),
+                        onCheckedChange = { onIntent(AudioPlayIntent.ChangePlayMode) },
+                    )
+                    SmallAnimatedButton(
+                        containerColor = Color.Transparent,
+                        checked = false,
+                        icon = Icons.Default.Speed,
+                        text = stringResource(R.string.audio_play_speed),
+                        contentDescription = stringResource(R.string.audio_play_speed),
+                        onCheckedChange = {
+                            activeAdjustment = activeAdjustment.toggle(AudioAdjustment.Speed)
+                        },
+                    )
+                    SmallAnimatedButton(
+                        containerColor = Color.Transparent,
+                        checked = false,
+                        icon = Icons.Default.Timer,
+                        text = stringResource(R.string.set_timer),
+                        contentDescription = stringResource(R.string.set_timer),
+                        onCheckedChange = {
+                            activeAdjustment = activeAdjustment.toggle(AudioAdjustment.Timer)
+                        },
+                    )
+                }
+                AnimatedVisibility(activeAdjustment == AudioAdjustment.Speed) {
+                    PlayerAdjustmentSlider(
+                        title = stringResource(R.string.audio_play_speed),
+                        value = speedPreview.coerceIn(AUDIO_SPEED_MIN, AUDIO_SPEED_MAX),
+                        valueLabel = String.format(Locale.ROOT, "%.1fX", speedPreview),
+                        startLabel = "0.5X",
+                        endLabel = "5.0X",
+                        enabled = state.status != Status.STOP,
+                        onValueChange = { speedPreview = it },
+                        onValueChangeFinished = {
+                            onIntent(AudioPlayIntent.SetSpeed(speedPreview))
+                        },
+                        valueRange = AUDIO_SPEED_MIN..AUDIO_SPEED_MAX,
+                        steps = 44,
+                    )
+                }
+                AnimatedVisibility(activeAdjustment == AudioAdjustment.Timer) {
+                    PlayerAdjustmentSlider(
+                        title = stringResource(R.string.set_timer),
+                        value = timerPreview.coerceIn(
+                            PlaybackTimer.MIN_MINUTES.toFloat(),
+                            PlaybackTimer.MAX_MINUTES.toFloat(),
+                        ),
+                        valueLabel = if (timerPreview == 0f) {
+                            stringResource(R.string.close)
+                        } else {
+                            stringResource(R.string.timer_m, timerPreview.roundToInt())
+                        },
+                        startLabel = stringResource(R.string.close),
+                        endLabel = stringResource(R.string.timer_m, PlaybackTimer.MAX_MINUTES),
+                        onValueChange = { timerPreview = it.roundToInt().toFloat() },
+                        onValueChangeFinished = {
+                            onIntent(AudioPlayIntent.SetTimer(timerPreview.roundToInt()))
+                        },
+                        valueRange = PlaybackTimer.MIN_MINUTES.toFloat()..PlaybackTimer.MAX_MINUTES.toFloat(),
+                        steps = PlaybackTimer.MAX_MINUTES - PlaybackTimer.MIN_MINUTES - 1,
+                    )
+                }
+            }
+        },
+    ) {
+        Box(
+            Modifier
+                .fillMaxSize()
+                .then(if (hazeEnabled) Modifier.hazeSource(pagerHazeState) else Modifier)
+        ) {
+            PlayerBackground(
+                name = state.bookName,
+                author = state.author,
+                path = state.coverPath,
+                sourceOrigin = state.sourceOrigin,
+                bgMode = state.bgMode,
+                modifier = if (flowingLightActive && textBackdrop != null) {
+                    Modifier.layerBackdrop(textBackdrop)
+                } else {
+                    Modifier
+                },
+            )
+            VerticalPager(
+                state = verticalPagerState,
+                modifier = Modifier.fillMaxSize(),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                pageNestedScrollConnection = verticalPagerNestedScrollConnection,
+            ) { page ->
+                if (page == 0) {
+                    AudioCoverPage(state, pageContentPadding)
+                } else {
+                    PlayerTocPage(
+                        chapters = state.chapters,
+                        currentIndex = state.chapterIndex,
+                        isPaused = !state.isPlaying,
+                        onSelect = { onIntent(AudioPlayIntent.SelectChapter(it)) },
+                        contentPadding = pageContentPadding,
+                    )
+                }
+            }
+        }
+    }
+
+    AudioSkipCreditsSheet(
+        show = state.activeSheet == AudioPlaySheet.SkipCredits,
+        state = state,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
+    AudioGainSheet(
+        show = state.activeSheet == AudioPlaySheet.Gain,
+        state = state,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+        onIntent = onIntent,
+    )
+    AppLogSheet(
+        show = state.activeSheet == AudioPlaySheet.Log,
+        onDismissRequest = { onIntent(AudioPlayIntent.DismissSheet) },
+    )
+}
+
+private const val AUDIO_GAIN_MIN = -6000
+private const val AUDIO_GAIN_MAX = 6000
+private const val CREDITS_MAX_SECONDS = 180
+
+@Composable
+private fun AudioSkipCreditsSheet(
+    show: Boolean,
+    state: AudioPlayUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    var openPreview by remember(state.openCredits) {
+        mutableFloatStateOf(state.openCredits.toFloat())
+    }
+    var closePreview by remember(state.closeCredits) {
+        mutableFloatStateOf(state.closeCredits.toFloat())
+    }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.audio_play_skip_credits),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.audio_play_open_credits),
+            value = openPreview,
+            valueLabel = stringResource(R.string.seconds_m, openPreview.roundToInt()),
+            startLabel = stringResource(R.string.seconds_m, 0),
+            endLabel = stringResource(R.string.seconds_m, CREDITS_MAX_SECONDS),
+            onValueChange = { openPreview = it.roundToInt().toFloat() },
+            onValueChangeFinished = {
+                onIntent(AudioPlayIntent.SetOpenCredits(openPreview.roundToInt()))
+            },
+            valueRange = 0f..CREDITS_MAX_SECONDS.toFloat(),
+            steps = CREDITS_MAX_SECONDS - 1,
+        )
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.audio_play_close_credits),
+            value = closePreview,
+            valueLabel = stringResource(R.string.seconds_m, closePreview.roundToInt()),
+            startLabel = stringResource(R.string.seconds_m, 0),
+            endLabel = stringResource(R.string.seconds_m, CREDITS_MAX_SECONDS),
+            onValueChange = { closePreview = it.roundToInt().toFloat() },
+            onValueChangeFinished = {
+                onIntent(AudioPlayIntent.SetCloseCredits(closePreview.roundToInt()))
+            },
+            valueRange = 0f..CREDITS_MAX_SECONDS.toFloat(),
+            steps = CREDITS_MAX_SECONDS - 1,
+        )
+    }
+}
+
+@Composable
+private fun AudioGainSheet(
+    show: Boolean,
+    state: AudioPlayUiState,
+    onDismissRequest: () -> Unit,
+    onIntent: (AudioPlayIntent) -> Unit,
+) {
+    var gainPreview by remember(state.audioGain) {
+        mutableFloatStateOf(state.audioGain.toFloat())
+    }
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.audio_play_gain),
+    ) {
+        PlayerAdjustmentSlider(
+            title = stringResource(R.string.audio_play_gain),
+            value = gainPreview.coerceIn(AUDIO_GAIN_MIN.toFloat(), AUDIO_GAIN_MAX.toFloat()),
+            valueLabel = formatGain(gainPreview.roundToInt()),
+            startLabel = formatGain(AUDIO_GAIN_MIN),
+            endLabel = formatGain(AUDIO_GAIN_MAX),
+            onValueChange = { gainPreview = (it / 500f).roundToInt() * 500f },
+            onValueChangeFinished = {
+                onIntent(AudioPlayIntent.SetAudioGain(gainPreview.roundToInt()))
+            },
+            valueRange = AUDIO_GAIN_MIN.toFloat()..AUDIO_GAIN_MAX.toFloat(),
+            steps = (AUDIO_GAIN_MAX - AUDIO_GAIN_MIN) / 500 - 1,
+        )
+    }
+}
+
+private fun formatGain(gainMb: Int): String {
+    val dB = gainMb / 1000f
+    return if (dB == 0f) {
+        "0.0 dB"
+    } else {
+        String.format(Locale.ROOT, "%+.1f dB", dB)
+    }
+}
+
+private const val AUDIO_SPEED_MIN = 0.5f
+private const val AUDIO_SPEED_MAX = 5.0f
+
+
+@Composable
+private fun AudioCoverPage(
+    state: AudioPlayUiState,
+    contentPadding: PaddingValues,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxSize()
+            .padding(contentPadding)
+            .padding(horizontal = 24.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        Box(
+            modifier = Modifier.fillMaxWidth(),
+            contentAlignment = Alignment.TopCenter,
+        ) {
+            BookCoverImage(
+                name = state.bookName,
+                author = state.author,
+                path = state.coverPath,
+                sourceOrigin = state.sourceOrigin,
+                modifier = Modifier
+                    .fillMaxWidth(0.64f)
+                    .aspectRatio(5f / 7f)
+                    .clip(RoundedCornerShape(8.dp))
+            )
+        }
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .weight(1f),
+            verticalArrangement = Arrangement.Center,
+            horizontalAlignment = Alignment.CenterHorizontally,
+        ) {
+            AppText(
+                text = state.bookName,
+                style = LegadoTheme.typography.titleLargeEmphasized,
+                textAlign = TextAlign.Center,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+            )
+            AppText(
+                text = state.chapterTitle,
+                modifier = Modifier.padding(top = 16.dp),
+                style = LegadoTheme.typography.titleMediumEmphasized,
+                color = LegadoTheme.colorScheme.onSurface,
+                textAlign = TextAlign.Center,
+                maxLines = 3,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+    }
+}
+
+
+private fun formatAudioTime(valueMs: Int): String {
+    val totalSeconds = (valueMs / 1000).coerceAtLeast(0)
+    val hours = totalSeconds / 3600
+    val minutes = (totalSeconds % 3600) / 60
+    val seconds = totalSeconds % 60
+    return if (hours > 0) {
+        String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds)
+    } else {
+        String.format(Locale.ROOT, "%02d:%02d", minutes, seconds)
+    }
+}
+
+private enum class AudioAdjustment { Speed, Timer }
+
+private fun AudioAdjustment?.toggle(value: AudioAdjustment): AudioAdjustment? =
+    if (this == value) null else value
+
+@Composable
+private fun playModeContentDescription(mode: AudioPlay.PlayMode): String = when (mode) {
+    AudioPlay.PlayMode.LIST_END_STOP -> stringResource(R.string.audio_play_mode_sequence)
+    AudioPlay.PlayMode.SINGLE_LOOP -> stringResource(R.string.audio_play_mode_single_loop)
+    AudioPlay.PlayMode.RANDOM -> stringResource(R.string.audio_play_mode_random)
+    AudioPlay.PlayMode.LIST_LOOP -> stringResource(R.string.audio_play_mode_loop)
+}
+
+private fun playModeIcon(mode: AudioPlay.PlayMode): ImageVector = when (mode) {
+    AudioPlay.PlayMode.LIST_END_STOP -> Icons.AutoMirrored.Filled.PlaylistPlay
+    AudioPlay.PlayMode.SINGLE_LOOP -> Icons.Default.RepeatOne
+    AudioPlay.PlayMode.RANDOM -> Icons.Default.Shuffle
+    AudioPlay.PlayMode.LIST_LOOP -> Icons.Default.Repeat
+}

+ 235 - 79
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayViewModel.kt

@@ -1,45 +1,207 @@
 package io.legado.app.ui.book.audio
 
 import android.app.Application
-import android.content.Intent
-import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
-import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.ReadSettingsGateway
-import org.koin.core.context.GlobalContext
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.EventBus
+import io.legado.app.constant.PreferKey
+import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.repository.BookRepository
-import io.legado.app.help.book.getBookSource
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.simulatedTotalChapterNum
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.compatDsInt
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 class AudioPlayViewModel(
-    application: Application,
+    private val application: Application,
+    private val coordinator: AudioPlayCoordinator,
     private val bookRepository: BookRepository,
-) : BaseViewModel(application) {
-    private val otherSettingsGateway get() = GlobalContext.get().get<OtherSettingsGateway>()
-    private val readSettingsGateway get() = GlobalContext.get().get<ReadSettingsGateway>()
-    val titleData = MutableLiveData<String>()
-    val coverData = MutableLiveData<String>()
-
-    fun initData(intent: Intent) = AudioPlay.apply {
-        execute {
-            val bookUrl = intent.getStringExtra("bookUrl") ?: book?.bookUrl ?: return@execute
-            val book = bookRepository.getBook(bookUrl) ?: return@execute
-            inBookshelf = intent.getBooleanExtra("inBookshelf", true)
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
+) : ViewModel() {
+
+    private val activeSheet = MutableStateFlow<AudioPlaySheet?>(null)
+
+    val uiState = combine(
+        coordinator.state,
+        AppConfigStore.observeInt(PreferKey.audioPlayBgMode),
+        activeSheet,
+    ) { source, bgMode, sheet ->
+        toUiState(source, bgMode ?: ReadAloudBgMode.Blur, sheet)
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5000),
+        initialValue = toUiState(coordinator.snapshot(), readBgMode(), null),
+    )
+
+    private val _effects = MutableSharedFlow<AudioPlayEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var initialized = false
+
+    fun init(bookUrl: String, inBookshelf: Boolean) {
+        if (initialized) return
+        initialized = true
+        collectMediaButton()
+        AudioPlay.inBookshelf = inBookshelf
+        // 兼容通知栏/耳机键等无 bookUrl 的入口:兜底到当前播放的书籍
+        val effectiveBookUrl = bookUrl.ifBlank { AudioPlay.book?.bookUrl.orEmpty() }
+        viewModelScope.launch {
+            val book = bookRepository.getBook(effectiveBookUrl) ?: return@launch
             initBook(book)
-        }.onFinally {
-            saveRead()
+        }
+    }
+
+    /** 耳机/媒体键事件(由 MediaButtonReceiver 在有声书路由激活时投递) */
+    private fun collectMediaButton() {
+        viewModelScope.launch {
+            eventFlow<Boolean>(EventBus.MEDIA_BUTTON).collect { play ->
+                if (play) coordinator.togglePlay()
+            }
+        }
+    }
+
+    private inline fun <reified T> eventFlow(tag: String) = callbackFlow {
+        val obs = Observer<T> { trySend(it) }
+        LiveEventBus.get<T>(tag).observeForever(obs)
+        awaitClose {
+            LiveEventBus.get<T>(tag).removeObserver(obs)
+        }
+    }
+
+    fun onLoadingChanged(loading: Boolean) {
+        coordinator.setLoading(loading)
+    }
+
+    fun onIntent(intent: AudioPlayIntent) {
+        when (intent) {
+            is AudioPlayIntent.Init -> init(intent.bookUrl, intent.inBookshelf)
+            AudioPlayIntent.Refresh -> coordinator.refresh()
+            AudioPlayIntent.TogglePlay -> coordinator.togglePlay()
+            AudioPlayIntent.Stop -> coordinator.stop()
+            AudioPlayIntent.PreviousChapter -> coordinator.previous()
+            AudioPlayIntent.NextChapter -> coordinator.next()
+            is AudioPlayIntent.SelectChapter -> coordinator.skipTo(intent.index)
+            is AudioPlayIntent.SeekTo -> coordinator.seekTo(intent.positionMs)
+            AudioPlayIntent.ChangePlayMode -> coordinator.changePlayMode()
+            is AudioPlayIntent.SetSpeed -> coordinator.setSpeed(intent.value)
+            is AudioPlayIntent.SetTimer -> coordinator.setTimer(intent.minutes)
+            is AudioPlayIntent.SetOpenCredits -> coordinator.setOpenCredits(intent.seconds)
+            is AudioPlayIntent.SetCloseCredits -> coordinator.setCloseCredits(intent.seconds)
+            is AudioPlayIntent.SetAudioGain -> coordinator.setAudioGain(intent.gainMb)
+            AudioPlayIntent.CycleBgMode -> cycleBgMode()
+            is AudioPlayIntent.OpenSheet -> activeSheet.value = intent.sheet
+            AudioPlayIntent.DismissSheet -> activeSheet.value = null
+            AudioPlayIntent.ChangeSource -> {
+                val book = AudioPlay.book ?: return
+                effect(AudioPlayEffect.OpenChangeSource(book.name, book.author))
+            }
+
+            AudioPlayIntent.Login -> {
+                val source = AudioPlay.bookSource ?: return
+                if (!source.loginUrl.isNullOrBlank()) {
+                    effect(AudioPlayEffect.OpenLogin(source.bookSourceUrl))
+                }
+            }
+
+            AudioPlayIntent.CopyPlayUrl -> effect(AudioPlayEffect.CopyPlayUrl)
+            AudioPlayIntent.EditSource -> {
+                val source = AudioPlay.bookSource ?: return
+                effect(AudioPlayEffect.OpenEditSource(source.bookSourceUrl))
+            }
+
+            AudioPlayIntent.ToggleWakeLock -> viewModelScope.launch {
+                otherSettingsGateway.update {
+                    it.copy(audioPlayUseWakeLock = !it.audioPlayUseWakeLock)
+                }
+            }
+
+            AudioPlayIntent.ToggleMediaControl -> viewModelScope.launch {
+                readAloudSettingsGateway.update {
+                    it.copy(
+                        systemMediaControlCompatibilityChange =
+                            !it.systemMediaControlCompatibilityChange
+                    )
+                }
+            }
+
+            AudioPlayIntent.SourceEdited -> coordinator.refreshBookSource()
+            AudioPlayIntent.BackPressed -> effect(AudioPlayEffect.Finish)
+        }
+    }
+
+    fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
+        viewModelScope.launch {
+            if (!book.isAudio) {
+                AudioPlay.stop()
+            }
+            withContext(Dispatchers.IO) {
+                AudioPlay.book?.migrateTo(
+                    book,
+                    toc,
+                    otherSettingsGateway.currentSettings.replaceEnableDefault,
+                    readSettingsGateway.currentSettings.chineseConverterType,
+                )
+                book.removeType(BookType.updateError)
+                AudioPlay.book?.delete()
+                bookRepository.insert(book)
+            }
+            if (book.isAudio) {
+                AudioPlay.book = book
+                AudioPlay.bookSource = source
+                bookRepository.insertChapters(*toc.toTypedArray())
+                AudioPlay.upDurChapter()
+                postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+                coordinator.refresh()
+            } else {
+                effect(AudioPlayEffect.OpenBookReader(book.bookUrl))
+            }
+        }
+    }
+
+    fun addToBookshelf(book: Book, toc: List<BookChapter>) {
+        viewModelScope.launch {
+            try {
+                withContext(Dispatchers.IO) {
+                    book.removeType(BookType.notShelf)
+                    if (book.order == 0) {
+                        book.order = bookRepository.getMinOrder() - 1
+                    }
+                    bookRepository.insert(book)
+                    bookRepository.insertChapters(*toc.toTypedArray())
+                }
+                effect(AudioPlayEffect.ShowToast(application.getString(R.string.book_added_to_shelf)))
+            } catch (e: Exception) {
+                AppLog.put("添加书籍到书架失败", e)
+                effect(AudioPlayEffect.ShowToast(application.getString(R.string.book_add_failed)))
+            }
         }
     }
 
@@ -50,30 +212,29 @@ class AudioPlayViewModel(
         } else {
             AudioPlay.resetData(book)
         }
-        titleData.postValue(book.name)
-        coverData.postValue(book.getDisplayCover())
         if (book.tocUrl.isEmpty() && !loadBookInfo(book)) {
             return
         }
         if (AudioPlay.chapterSize == 0 && !loadChapterList(book)) {
             return
         }
+        coordinator.refresh()
     }
 
     private suspend fun loadBookInfo(book: Book): Boolean {
         val bookSource = AudioPlay.bookSource ?: return true
-        try {
+        return try {
             WebBook.getBookInfoAwait(bookSource, book)
-            return true
+            true
         } catch (e: Exception) {
             AppLog.put("详情页出错: ${e.localizedMessage}", e, true)
-            return false
+            false
         }
     }
 
     private suspend fun loadChapterList(book: Book): Boolean {
         val bookSource = AudioPlay.bookSource ?: return true
-        try {
+        return try {
             val oldBook = book.copy()
             val cList = WebBook.getChapterListAwait(bookSource, book).getOrThrow()
             if (oldBook.bookUrl == book.bookUrl) {
@@ -86,65 +247,60 @@ class AudioPlayViewModel(
             AudioPlay.chapterSize = cList.size
             AudioPlay.simulatedChapterSize = book.simulatedTotalChapterNum()
             AudioPlay.upDurChapter()
-            return true
+            true
         } catch (e: Exception) {
-            context.toastOnUi(R.string.error_load_toc)
-            return false
+            effect(AudioPlayEffect.ShowToast(application.getString(R.string.error_load_toc)))
+            false
         }
     }
 
-    fun upSource() {
-        execute {
-            val book = AudioPlay.book ?: return@execute
-            AudioPlay.bookSource = book.getBookSource()
+    private fun cycleBgMode() {
+        val next = when (readBgMode()) {
+            ReadAloudBgMode.Solid -> ReadAloudBgMode.Blur
+            ReadAloudBgMode.Blur -> ReadAloudBgMode.FlowingLight
+            ReadAloudBgMode.FlowingLight -> ReadAloudBgMode.Transparent
+            else -> ReadAloudBgMode.Solid
         }
+        AppConfigStore.putInt(PreferKey.audioPlayBgMode, next)
     }
 
-    fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
-        execute {
-            AudioPlay.book?.migrateTo(
-                book,
-                toc,
-                otherSettingsGateway.currentSettings.replaceEnableDefault,
-                readSettingsGateway.currentSettings.chineseConverterType,
-            )
-            book.removeType(BookType.updateError)
-            AudioPlay.book?.delete()
-            bookRepository.insert(book)
-            AudioPlay.book = book
-            AudioPlay.bookSource = source
-            bookRepository.insertChapters(*toc.toTypedArray())
-            AudioPlay.upDurChapter()
-        }.onFinally {
-            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
-        }
-    }
+    private fun readBgMode(): Int =
+        AppConfigStore.preferences.compatDsInt(PreferKey.audioPlayBgMode)
+            ?: ReadAloudBgMode.Blur
 
-    fun removeFromBookshelf(success: (() -> Unit)?) {
-        execute {
-            AudioPlay.book?.let {
-                bookRepository.delete(it)
-            }
-        }.onSuccess {
-            success?.invoke()
-        }
-    }
-
-    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
-        execute {
-            book.removeType(BookType.notShelf)
-            if (book.order == 0) {
-                book.order = bookRepository.getMinOrder() - 1
-            }
+    private fun toUiState(
+        source: AudioPlaySourceState,
+        bgMode: Int,
+        sheet: AudioPlaySheet?,
+    ): AudioPlayUiState = AudioPlayUiState(
+        bookUrl = source.bookUrl,
+        bookName = source.bookName,
+        author = source.author,
+        coverPath = source.coverPath,
+        sourceOrigin = source.sourceOrigin,
+        chapterIndex = source.chapterIndex,
+        chapterTitle = source.chapterTitle,
+        chapters = source.chapters,
+        status = source.status,
+        isLoading = source.isLoading,
+        position = source.position,
+        duration = source.duration,
+        speed = source.speed,
+        timerMinutes = source.timerMinutes,
+        playMode = source.playMode,
+        bgMode = bgMode,
+        canLogin = source.canLogin,
+        wakeLockEnabled = source.wakeLockEnabled,
+        mediaControlEnabled = source.mediaControlEnabled,
+        canPrevious = source.canPrevious,
+        canNext = source.canNext,
+        openCredits = source.openCredits,
+        closeCredits = source.closeCredits,
+        audioGain = source.audioGain,
+        activeSheet = sheet,
+    )
 
-            bookRepository.insert(book)
-            bookRepository.insertChapters(*toc.toTypedArray())
-        }.onSuccess {
-            success?.invoke()
-        }.onError {
-            AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
-        }
+    private fun effect(value: AudioPlayEffect) {
+        _effects.tryEmit(value)
     }
-
 }

+ 5 - 13
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -3,7 +3,6 @@ package io.legado.app.ui.book.info
 import android.app.Activity
 import android.content.Intent
 import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -24,7 +23,6 @@ import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
 import io.legado.app.model.SourceCallBack
-import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.file.HandleFileContract
@@ -51,6 +49,7 @@ fun BookInfoRouteScreen(
     onOpenSourceLogin: (String) -> Unit,
     onOpenReader: (bookUrl: String, inBookshelf: Boolean, chapterChanged: Boolean) -> Unit = { _, _, _ -> },
     onOpenMangaReader: (bookUrl: String, inBookshelf: Boolean, chapterChanged: Boolean) -> Unit = { _, _, _ -> },
+    onOpenAudioPlay: (bookUrl: String, inBookshelf: Boolean) -> Unit = { _, _ -> },
     onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> },
     onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> },
     onOpenCharacterDetail: (bookUrl: String, characterId: String?) -> Unit = { _, _ -> },
@@ -83,11 +82,6 @@ fun BookInfoRouteScreen(
             viewModel.onInfoEdited()
         }
     }
-    val readBookResult = rememberLauncherForActivityResult(
-        ActivityResultContracts.StartActivityForResult()
-    ) {
-        viewModel.onReaderResult(it.resultCode)
-    }
 
     LaunchedEffect(bookUrl, name, author, origin, coverPath, viewModel) {
         viewModel.initData(
@@ -128,13 +122,11 @@ fun BookInfoRouteScreen(
 
                 is BookInfoEffect.OpenReader -> {
                     when {
-                        effect.book.isAudio -> readBookResult.launch(
-                            Intent(activity, AudioPlayActivity::class.java).apply {
-                                putExtra("bookUrl", effect.book.bookUrl)
-                                putExtra("inBookshelf", effect.inBookshelf)
-                                putExtra("chapterChanged", effect.chapterChanged)
-                            }
+                        effect.book.isAudio -> onOpenAudioPlay(
+                            effect.book.bookUrl,
+                            effect.inBookshelf
                         )
+
                         !effect.book.isLocal && effect.book.isImage && showMangaUi -> {
                             onOpenMangaReader(
                                 effect.book.bookUrl,

+ 0 - 13
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -433,19 +433,6 @@ class BookInfoViewModel(
         }
     }
 
-    fun onReaderResult(resultCode: Int) {
-        when (resultCode) {
-            RESULT_OK -> {
-                inBookshelf = true
-                syncUiState()
-            }
-
-            READER_RESULT_DELETED -> {
-                emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK))
-            }
-        }
-    }
-
     fun refreshShelfState() {
         val bookUrl = currentBook?.bookUrl ?: return
         execute {

+ 2 - 9
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.readaloud.player
 
 import androidx.compose.runtime.Stable
+import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -10,14 +11,6 @@ data class ReadAloudTextLineUi(
     val chapterPosition: Int,
 )
 
-@Stable
-data class ReadAloudChapterUi(
-    val index: Int,
-    val title: String,
-    val isVolume: Boolean,
-    val tocLevel: Int,
-)
-
 @Stable
 data class ReadAloudPlayerUiState(
     val bookUrl: String = "",
@@ -27,7 +20,7 @@ data class ReadAloudPlayerUiState(
     val sourceOrigin: String? = null,
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
-    val chapters: ImmutableList<ReadAloudChapterUi> = persistentListOf(),
+    val chapters: ImmutableList<PlayerChapterUi> = persistentListOf(),
     val chapterText: String = "",
     val textLines: ImmutableList<ReadAloudTextLineUi> = persistentListOf(),
     val activeTextLine: Int = -1,

+ 22 - 419
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -4,19 +4,16 @@ import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
-import androidx.compose.foundation.gestures.detectHorizontalDragGestures
 import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -28,14 +25,12 @@ import androidx.compose.foundation.layout.asPaddingValues
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
@@ -71,20 +66,13 @@ import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.geometry.CornerRadius
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.RoundRect
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.drawscope.clipPath
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
@@ -101,18 +89,17 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
 import io.legado.app.ui.util.rememberBlurBackdrop
 import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.AppSlider
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
-import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
-import io.legado.app.ui.widget.components.effect.BgEffectBackground
-import io.legado.app.ui.widget.components.effect.BgEffectConfig
-import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
-import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
+import io.legado.app.ui.widget.components.player.PlayerAdjustmentSlider
+import io.legado.app.ui.widget.components.player.PlayerBackground
+import io.legado.app.ui.widget.components.player.PlayerProgressSlider
+import io.legado.app.ui.widget.components.player.PlayerTocPage
+import io.legado.app.ui.widget.components.player.playerBgModeLabel
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.delay
@@ -121,7 +108,6 @@ import kotlinx.coroutines.launch
 import top.yukonga.miuix.kmp.blur.BlendColorEntry
 import top.yukonga.miuix.kmp.blur.BlurBlendMode
 import top.yukonga.miuix.kmp.blur.BlurColors
-import top.yukonga.miuix.kmp.blur.isRuntimeShaderSupported
 import top.yukonga.miuix.kmp.blur.layerBackdrop
 import top.yukonga.miuix.kmp.blur.textureBlur
 import kotlin.math.abs
@@ -381,7 +367,7 @@ fun ReadAloudPlayerScreenContent(
                         SmallPlainButton(
                             onClick = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
                             icon = Icons.Default.WbTwilight,
-                            contentDescription = bgModeLabel(state.bgMode)
+                            contentDescription = playerBgModeLabel(state.bgMode)
                         )
                         SmallPlainButton(
                             onClick = {
@@ -463,8 +449,12 @@ fun ReadAloudPlayerScreenContent(
             .fillMaxSize()
             .then(if (hazeEnabled) Modifier.hazeSource(pagerHazeState) else Modifier)
         ) {
-            ReadAloudBackground(
-                state = state,
+            PlayerBackground(
+                name = state.bookName,
+                author = state.author,
+                path = state.coverPath,
+                sourceOrigin = state.sourceOrigin,
+                bgMode = state.bgMode,
                 modifier = if (flowingLightActive && textBackdrop != null) {
                     Modifier.layerBackdrop(textBackdrop)
                 } else {
@@ -486,10 +476,14 @@ fun ReadAloudPlayerScreenContent(
                         if (verticalPage == 0) {
                             CoverPage(state, pageContentPadding, flowingTextModifier)
                         } else {
-                            ChapterTocPage(
-                                state = state,
+                            PlayerTocPage(
+                                chapters = state.chapters,
+                                currentIndex = state.chapterIndex,
+                                isPaused = state.isPaused,
+                                onSelect = {
+                                    onIntent(ReadAloudPlayerIntent.SelectChapter(it))
+                                },
                                 contentPadding = pageContentPadding,
-                                onIntent = onIntent,
                             )
                         }
                     }
@@ -507,269 +501,8 @@ fun ReadAloudPlayerScreenContent(
     }
 }
 
-@Composable
-private fun PlayerAdjustmentSlider(
-    title: String,
-    value: Float,
-    valueLabel: String,
-    startLabel: String,
-    endLabel: String,
-    onValueChange: (Float) -> Unit,
-    onValueChangeFinished: () -> Unit,
-    valueRange: ClosedFloatingPointRange<Float>,
-    steps: Int,
-) {
-    NormalCard(
-        modifier = Modifier
-            .fillMaxWidth()
-            .padding(horizontal = 16.dp, vertical = 4.dp),
-        cornerRadius = 28.dp,
-        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
-    ) {
-        Column(
-            modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
-            verticalArrangement = Arrangement.spacedBy(8.dp)
-        ) {
-            Row(
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                AppText(
-                    modifier = Modifier.weight(1f),
-                    text = title,
-                    style = LegadoTheme.typography.labelMediumEmphasized,
-                )
-                TextCard(
-                    text = valueLabel,
-                )
-            }
-
-            AppSlider(
-                value = value,
-                onValueChange = onValueChange,
-                onValueChangeFinished = onValueChangeFinished,
-                valueRange = valueRange,
-                steps = steps,
-                modifier = Modifier.fillMaxWidth(),
-            )
 
-            Row(
-                modifier = Modifier
-                    .fillMaxWidth(),
-                horizontalArrangement = Arrangement.SpaceBetween,
-            ) {
-                AppText(
-                    text = startLabel,
-                    style = LegadoTheme.typography.labelSmall,
-                    color = LegadoTheme.colorScheme.onSurfaceVariant,
-                )
-                AppText(
-                    text = endLabel,
-                    style = LegadoTheme.typography.labelSmall,
-                    color = LegadoTheme.colorScheme.onSurfaceVariant,
-                )
-            }
-        }
-    }
-}
 
-@Composable
-private fun ChapterTocPage(
-    state: ReadAloudPlayerUiState,
-    contentPadding: PaddingValues,
-    onIntent: (ReadAloudPlayerIntent) -> Unit,
-) {
-    val listState = rememberLazyListState()
-    LaunchedEffect(state.chapterIndex, state.chapters) {
-        val currentItem = state.chapters.indexOfFirst {
-            !it.isVolume && it.index == state.chapterIndex
-        }
-        if (currentItem >= 0) {
-            listState.scrollToItem((currentItem - 2).coerceAtLeast(0))
-        }
-    }
-
-    LazyColumn(
-        state = listState,
-        modifier = Modifier.fillMaxSize(),
-        contentPadding = PaddingValues(
-            start = 16.dp,
-            top = contentPadding.calculateTopPadding() + 12.dp,
-            end = 16.dp,
-            bottom = contentPadding.calculateBottomPadding() + 12.dp,
-        ),
-        verticalArrangement = Arrangement.spacedBy(8.dp),
-    ) {
-        items(
-            items = state.chapters,
-            key = { chapter ->
-                if (chapter.isVolume) "player-volume-${chapter.index}"
-                else "player-chapter-${chapter.index}"
-            },
-            contentType = { chapter -> if (chapter.isVolume) "volume" else "chapter" },
-        ) { chapter ->
-            if (chapter.isVolume) {
-                AppText(
-                    text = chapter.title,
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .padding(
-                            start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp,
-                            top = 12.dp,
-                            bottom = 4.dp,
-                        ),
-                    style = LegadoTheme.typography.titleSmallEmphasized,
-                    color = LegadoTheme.colorScheme.primary,
-                    maxLines = 1,
-                    overflow = TextOverflow.Ellipsis,
-                )
-            } else {
-                val isCurrent = chapter.index == state.chapterIndex
-                NormalCard(
-                    onClick = { onIntent(ReadAloudPlayerIntent.SelectChapter(chapter.index)) },
-                    cornerRadius = 12.dp,
-                    containerColor = if (isCurrent) {
-                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
-                    } else {
-                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0f)
-                    },
-                    contentColor = if (isCurrent) {
-                        LegadoTheme.colorScheme.onSurface
-                    } else {
-                        LegadoTheme.colorScheme.onSurface
-                    },
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .padding(start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp),
-                ) {
-                    Row(
-                        modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
-                        verticalAlignment = Alignment.CenterVertically,
-                    ) {
-                        if (isCurrent) {
-                            AppIcon(
-                                imageVector = if (state.isPaused) {
-                                    Icons.Default.PlayArrow
-                                } else {
-                                    Icons.Default.Pause
-                                },
-                                contentDescription = null,
-                                modifier = Modifier
-                                    .padding(end = 8.dp)
-                                    .size(16.dp),
-                            )
-                        }
-                        Column(modifier = Modifier.weight(1f)) {
-                            AppText(
-                                text = chapter.title,
-                                style = if (isCurrent) {
-                                    LegadoTheme.typography.bodyMediumEmphasized
-                                } else {
-                                    LegadoTheme.typography.bodyMedium
-                                },
-                                maxLines = 2,
-                                overflow = TextOverflow.Ellipsis,
-                            )
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
-@Composable
-private fun PlayerProgressSlider(
-    value: Float,
-    onValueChange: (Float) -> Unit,
-    valueRange: ClosedFloatingPointRange<Float>,
-    modifier: Modifier = Modifier
-) {
-    val rangeLength = (valueRange.endInclusive - valueRange.start).coerceAtLeast(1f)
-    var isDragging by remember { mutableStateOf(false) }
-    var widthPx by remember { mutableFloatStateOf(1f) }
-    var previewFraction by remember(valueRange) {
-        mutableFloatStateOf(((value - valueRange.start) / rangeLength).coerceIn(0f, 1f))
-    }
-    LaunchedEffect(value, valueRange) {
-        if (!isDragging) {
-            previewFraction = ((value - valueRange.start) / rangeLength).coerceIn(0f, 1f)
-        }
-    }
-    val trackScale by animateFloatAsState(
-        targetValue = if (isDragging) 1.35f else 1f,
-        animationSpec = tween(durationMillis = 200),
-        label = "progress_track_scale",
-    )
-    val activeColor = LegadoTheme.colorScheme.onSurface
-    val inactiveColor = LegadoTheme.colorScheme.onSurface.copy(alpha = 0.38f)
-    Canvas(
-        modifier = modifier
-            .fillMaxWidth()
-            .height(18.dp)
-            .graphicsLayer { scaleY = trackScale }
-            .onSizeChanged { widthPx = it.width.toFloat().coerceAtLeast(1f) }
-            .pointerInput(valueRange, widthPx) {
-                fun updatePreview(x: Float) {
-                    previewFraction = (x / widthPx).coerceIn(0f, 1f)
-                }
-                detectHorizontalDragGestures(
-                    onDragStart = { offset ->
-                        isDragging = true
-                        updatePreview(offset.x)
-                    },
-                    onDragEnd = {
-                        isDragging = false
-                        onValueChange(valueRange.start + previewFraction * rangeLength)
-                    },
-                    onDragCancel = { isDragging = false },
-                    onHorizontalDrag = { change, _ ->
-                        change.consume()
-                        updatePreview(change.position.x)
-                    },
-                )
-            },
-    ) {
-        val centerY = size.height / 2f
-        val progressX = size.width * previewFraction
-        val trackHeight = 2.dp.toPx()
-        val trackRadius = trackHeight / 2f
-        val trackClip = Path().apply {
-            addRoundRect(
-                RoundRect(
-                    left = 0f,
-                    top = centerY - trackRadius,
-                    right = size.width,
-                    bottom = centerY + trackRadius,
-                    cornerRadius = CornerRadius(trackRadius),
-                )
-            )
-        }
-        val tickSpacing = 4.dp.toPx()
-        val tickHeight = 2.dp.toPx()
-        clipPath(trackClip) {
-            var tickX = 0f
-            while (tickX <= size.width) {
-                drawLine(
-                    color = inactiveColor,
-                    start = Offset(tickX, centerY - tickHeight / 2f),
-                    end = Offset(tickX, centerY + tickHeight / 2f),
-                    strokeWidth = 1.5.dp.toPx(),
-                    cap = StrokeCap.Round,
-                )
-                tickX += tickSpacing
-            }
-            if (progressX > 0f) {
-                drawLine(
-                    color = activeColor,
-                    start = Offset(0f, centerY),
-                    end = Offset(progressX, centerY),
-                    strokeWidth = trackHeight,
-                    cap = StrokeCap.Butt,
-                )
-            }
-        }
-    }
-}
 @Composable
 private fun CoverPage(
     state: ReadAloudPlayerUiState,
@@ -1009,131 +742,6 @@ private const val BOTTOM_BAR_RESTORE_DELAY_MILLIS = 650L
 private fun PlayerAdjustment?.toggle(value: PlayerAdjustment): PlayerAdjustment? =
     if (this == value) null else value
 
-@Composable
-private fun ReadAloudBackground(
-    state: ReadAloudPlayerUiState,
-    modifier: Modifier = Modifier,
-) {
-    when (state.bgMode) {
-        ReadAloudBgMode.Blur -> {
-            CoverBlurBackdrop(
-                state.bookName, state.author, state.coverPath, state.sourceOrigin,
-                modifier = modifier,
-            )
-        }
-
-        ReadAloudBgMode.FlowingLight -> {
-            val shaderSupported = remember { isRuntimeShaderSupported() }
-            if (shaderSupported) {
-                val coverPreset = rememberCoverDerivedPreset()
-                Box(modifier.fillMaxSize()) {
-                    CoverBlurBackdrop(
-                        state.bookName, state.author, state.coverPath, state.sourceOrigin,
-                        blurRadius = 64.dp,
-                    )
-                    BgEffectBackground(
-                        dynamicBackground = true,
-                        isOs3Effect = true,
-                        isFullSize = true,
-                        drawSurface = false,
-                        customPreset = coverPreset,
-                        modifier = Modifier.fillMaxSize(),
-                        alpha = { 0.5f },
-                    ) {
-                        Box(Modifier.fillMaxSize())
-                    }
-                }
-            } else {
-                CoverBlurBackdrop(
-                    state.bookName, state.author, state.coverPath, state.sourceOrigin,
-                    modifier = modifier,
-                )
-            }
-        }
-
-        ReadAloudBgMode.Transparent -> {
-            Box(modifier = modifier.fillMaxSize())
-        }
-
-        ReadAloudBgMode.Solid -> {
-            Box(
-                modifier = modifier
-                    .fillMaxSize()
-                    .background(LegadoTheme.colorScheme.surface),
-            )
-        }
-
-        else -> {
-            Box(
-                modifier = modifier
-                    .fillMaxSize()
-                    .background(LegadoTheme.colorScheme.surface),
-            )
-        }
-    }
-}
-
-@Composable
-private fun rememberCoverDerivedPreset(): BgEffectConfig.Config {
-    val primary = LegadoTheme.colorScheme.primary
-    val surface = LegadoTheme.colorScheme.secondaryContainer
-    val tertiary = LegadoTheme.colorScheme.secondary
-    val isDark = LegadoTheme.isDark
-
-    return remember(primary, surface, tertiary, isDark) {
-        val darken = if (isDark) 0.68f else 0.88f
-        val p = primary.copy(
-            red = primary.red * darken,
-            green = primary.green * darken,
-            blue = primary.blue * darken,
-        ).toShaderColor()
-        val s = surface.copy(
-            red = surface.red * darken,
-            green = surface.green * darken,
-            blue = surface.blue * darken,
-        ).toShaderColor()
-        val t = tertiary.copy(
-            red = tertiary.red * darken,
-            green = tertiary.green * darken,
-            blue = tertiary.blue * darken,
-        ).toShaderColor()
-
-        val m = floatArrayOf(
-            (p[0] + t[0]) / 2f,
-            (p[1] + t[1]) / 2f,
-            (p[2] + t[2]) / 2f,
-            1.0f,
-        )
-
-        fun stage(a: FloatArray, b: FloatArray, c: FloatArray, d: FloatArray): FloatArray =
-            floatArrayOf(
-                a[0], a[1], a[2], a[3],
-                b[0], b[1], b[2], b[3],
-                c[0], c[1], c[2], c[3],
-                d[0], d[1], d[2], d[3],
-            )
-
-        BgEffectConfig.Config(
-            points = floatArrayOf(
-                0.8f, 0.2f, 1.0f,
-                0.8f, 0.9f, 1.0f,
-                0.2f, 0.9f, 1.0f,
-                0.2f, 0.2f, 1.0f,
-            ),
-            colors1 = stage(p, t, s, m),
-            colors2 = stage(t, s, m, p),
-            colors3 = stage(s, m, p, t),
-            colorInterpPeriod = 96.0f,
-            lightOffset = if (isDark) 0.0f else 0.1f,
-            saturateOffset = if (isDark) 0.17f else 0.2f,
-            pointOffset = if (isDark) 0.4f else 0.2f,
-        )
-    }
-}
-
-private fun Color.toShaderColor(): FloatArray =
-    floatArrayOf(red, green, blue, alpha)
-
 @Composable
 private fun flowingTextBlend(): List<BlendColorEntry> {
     val isDark = LegadoTheme.isDark
@@ -1154,11 +762,6 @@ private fun flowingTextBlend(): List<BlendColorEntry> {
     }
 }
 
-@Composable
-private fun bgModeLabel(mode: Int): String = when (mode) {
-    ReadAloudBgMode.Solid -> stringResource(R.string.read_aloud_bg_solid)
-    ReadAloudBgMode.Blur -> stringResource(R.string.read_aloud_bg_blur)
-    ReadAloudBgMode.FlowingLight -> stringResource(R.string.read_aloud_bg_flowing_light)
-    ReadAloudBgMode.Transparent -> stringResource(R.string.read_aloud_bg_transparent)
-    else -> stringResource(R.string.read_aloud_bg_blur)
-}
+
+
+

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -6,6 +6,7 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsInt
+import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -83,7 +84,7 @@ class ReadAloudPlayerViewModel(
             it.chapterPosition <= source.chapterPosition
         }
         val chapters = source.chapters.map { chapter ->
-            ReadAloudChapterUi(
+            PlayerChapterUi(
                 index = chapter.index,
                 title = chapter.title,
                 isVolume = chapter.isVolume,

+ 50 - 1
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -38,6 +38,9 @@ import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.constant.AppConst.appInfo
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -47,9 +50,12 @@ import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.storage.Backup
 import io.legado.app.help.update.AppUpdateGitHub
 import io.legado.app.lib.dialogs.alert
+import io.legado.app.model.AudioPlay
 import io.legado.app.service.WebService
 import io.legado.app.ui.about.CrashLogsDialog
 import io.legado.app.ui.about.UpdateDialog
+import io.legado.app.ui.book.audio.AudioPlayViewModel
+import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
 import io.legado.app.ui.book.read.ReadBookInputHandler
 import io.legado.app.ui.book.read.ReadBookRouteHost
 import io.legado.app.ui.book.read.page.entities.PageDirection
@@ -74,7 +80,11 @@ import kotlin.coroutines.suspendCoroutine
 /**
  * 主界面
  */
-open class MainActivity : BaseComposeActivity() {
+open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack,
+    ChangeBookSourceDialog.CallBack {
+
+    /** 当前激活的有声书播放器 ViewModel(由有声书路由在生命周期内设置/清理) */
+    internal var activeAudioPlayViewModel: AudioPlayViewModel? = null
 
     companion object {
         private const val KEY_RESTORE_READ_ROUTE = "restoreReadRoute"
@@ -87,6 +97,9 @@ open class MainActivity : BaseComposeActivity() {
         @Volatile
         var hasActiveReadBookRoute: Boolean = false
 
+        @Volatile
+        var hasActiveAudioPlayRoute: Boolean = false
+
         fun createLauncherIntent(context: Context): Intent =
             MainIntent.createLauncherIntent(context)
 
@@ -183,6 +196,16 @@ open class MainActivity : BaseComposeActivity() {
             chapterChanged = chapterChanged,
         )
 
+        fun createAudioPlayIntent(
+            context: Context,
+            bookUrl: String? = null,
+            inBookshelf: Boolean = true,
+        ): Intent = MainIntent.createAudioPlayIntent(
+            context = context,
+            bookUrl = bookUrl,
+            inBookshelf = inBookshelf,
+        )
+
         fun createSearchIntent(
             context: Context,
             key: String? = null,
@@ -644,6 +667,32 @@ open class MainActivity : BaseComposeActivity() {
         }
     }
 
+    // ===== AudioPlay.CallBack(有声书播放器路由注册,转发加载状态给当前播放器)=====
+
+    override fun upLoading(loading: Boolean) {
+        activeAudioPlayViewModel?.onLoadingChanged(loading)
+    }
+
+    override fun upLyric(lyric: String?) {
+        // 歌词暂不在界面展示
+    }
+
+    override fun upLyricP(position: Int) {
+        // 歌词暂不在界面展示
+    }
+
+    // ===== ChangeBookSourceDialog.CallBack(有声书换源对话框,转发给当前播放器)=====
+
+    override val oldBook: Book?
+        get() = AudioPlay.book
+
+    override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
+        activeAudioPlayViewModel?.changeTo(source, book, toc)
+    }
+
+    override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
+        activeAudioPlayViewModel?.addToBookshelf(book, toc)
+    }
 
 }
 

+ 11 - 0
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -226,6 +226,17 @@ object MainIntent {
         putExtra(EXTRA_CHAPTER_CHANGED, chapterChanged)
     }
 
+    fun createAudioPlayIntent(
+        context: Context,
+        bookUrl: String? = null,
+        inBookshelf: Boolean = true,
+    ): Intent = createLauncherIntent(context).apply {
+        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+        putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_AUDIO_PLAY)
+        bookUrl?.let { putExtra(EXTRA_BOOK_URL, it) }
+        putExtra(EXTRA_IN_BOOKSHELF, inBookshelf)
+    }
+
     fun createSearchIntent(
         context: Context,
         key: String? = null,

+ 150 - 0
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -1,7 +1,9 @@
 package io.legado.app.ui.main
 
+import android.app.Activity
 import android.content.Intent
 import android.net.Uri
+import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.ExperimentalSharedTransitionApi
@@ -28,15 +30,27 @@ import androidx.navigation3.runtime.NavKey
 import androidx.navigation3.runtime.entryProvider
 import androidx.navigation3.ui.LocalNavAnimatedContentScope
 import androidx.navigation3.ui.NavDisplay
+import coil3.ImageLoader
 import io.legado.app.R
+import io.legado.app.constant.BookType
+import io.legado.app.constant.Status
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.settings.AppUiConfiguration
+import io.legado.app.help.coil.CoverExtras
+import io.legado.app.model.AudioPlay
 import io.legado.app.model.Download
+import io.legado.app.model.SourceCallBack
+import io.legado.app.service.AudioPlayService
 import io.legado.app.ui.about.AboutEffect
 import io.legado.app.ui.about.AboutScreen
 import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.ai.chat.AiChatRouteScreen
+import io.legado.app.ui.book.audio.AudioPlayEffect
+import io.legado.app.ui.book.audio.AudioPlayIntent
+import io.legado.app.ui.book.audio.AudioPlayScreenContent
+import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
+import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
 import io.legado.app.ui.book.explore.ExploreShowIntent
 import io.legado.app.ui.book.explore.ExploreShowRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowViewModel
@@ -124,7 +138,12 @@ import io.legado.app.ui.rss.source.edit.RssSourceEditRoute
 import io.legado.app.ui.rss.source.edit.RssSourceEditViewModel
 import io.legado.app.ui.rss.source.manage.RssSourceRouteScreen
 import io.legado.app.ui.rss.subscription.RuleSubRouteScreen
+import io.legado.app.ui.theme.ProvideThemeOverride
+import io.legado.app.ui.theme.rememberImageSeedColor
+import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.utils.openUrl
+import io.legado.app.utils.sendToClip
+import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivityForBook
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
@@ -134,6 +153,7 @@ import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.koinViewModel
+import org.koin.compose.koinInject
 import org.koin.core.parameter.parametersOf
 
 @OptIn(ExperimentalSharedTransitionApi::class)
@@ -709,6 +729,128 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
+    entry<MainRouteAudioPlay> { route ->
+        val audioPlayViewModel = koinViewModel<AudioPlayViewModel>(
+            key = "AudioPlay:${route.bookUrl}",
+        )
+        val lifecycleOwner = LocalLifecycleOwner.current
+        val uiState by audioPlayViewModel.uiState.collectAsStateWithLifecycle()
+        val imageLoader: ImageLoader = koinInject()
+        val sourceEditResult = rememberLauncherForActivityResult(
+            ActivityResultContracts.StartActivityForResult()
+        ) {
+            if (it.resultCode == Activity.RESULT_OK) {
+                audioPlayViewModel.onIntent(AudioPlayIntent.SourceEdited)
+            }
+        }
+
+        fun copyAudioPlayUrl() {
+            AudioPlay.book?.let {
+                SourceCallBack.callBackBtn(
+                    this@mainEntryProvider,
+                    SourceCallBack.CLICK_COPY_PLAY_URL,
+                    AudioPlay.bookSource,
+                    it,
+                    AudioPlay.durChapter,
+                    BookType.audio,
+                ) {
+                    sendToClip(AudioPlayService.url)
+                }
+            }
+        }
+
+        fun finishAudioPlay() {
+            if (AudioPlay.inBookshelf) {
+                SourceCallBack.callBackBook(
+                    SourceCallBack.END_READ,
+                    AudioPlay.bookSource,
+                    AudioPlay.book,
+                    AudioPlay.durChapter
+                )
+            }
+            onNavigateBack()
+        }
+
+        LaunchedEffect(route, audioPlayViewModel) {
+            audioPlayViewModel.onIntent(
+                AudioPlayIntent.Init(route.bookUrl.orEmpty(), route.inBookshelf)
+            )
+        }
+        DisposableEffect(audioPlayViewModel, lifecycleOwner) {
+            MainActivity.hasActiveAudioPlayRoute = true
+            this@mainEntryProvider.activeAudioPlayViewModel = audioPlayViewModel
+            AudioPlay.register(this@mainEntryProvider)
+            onDispose {
+                if (AudioPlay.status != Status.PLAY) {
+                    AudioPlay.stop()
+                }
+                AudioPlay.unregister(this@mainEntryProvider)
+                if (this@mainEntryProvider.activeAudioPlayViewModel === audioPlayViewModel) {
+                    this@mainEntryProvider.activeAudioPlayViewModel = null
+                }
+                MainActivity.hasActiveAudioPlayRoute = false
+            }
+        }
+        LaunchedEffect(audioPlayViewModel) {
+            audioPlayViewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    is AudioPlayEffect.OpenChangeSource -> showDialogFragment(
+                        ChangeBookSourceDialog(effect.bookName, effect.author)
+                    )
+
+                    is AudioPlayEffect.OpenLogin -> startActivity(
+                        MainActivity.createSourceLoginIntent(
+                            this@mainEntryProvider,
+                            SourceLoginType.BookSource,
+                            effect.sourceUrl
+                        )
+                    )
+
+                    AudioPlayEffect.CopyPlayUrl -> copyAudioPlayUrl()
+                    is AudioPlayEffect.OpenEditSource -> sourceEditResult.launch(
+                        MainActivity.createBookSourceEditIntent(
+                            this@mainEntryProvider,
+                            effect.sourceUrl
+                        )
+                    )
+
+                    is AudioPlayEffect.ShowToast -> toastOnUi(effect.message)
+                    is AudioPlayEffect.OpenBookReader -> {
+                        startActivity(
+                            MainActivity.createReadBookIntent(
+                                this@mainEntryProvider,
+                                effect.bookUrl
+                            )
+                        )
+                        onNavigateBack()
+                    }
+
+                    AudioPlayEffect.Finish -> finishAudioPlay()
+                }
+            }
+        }
+        BackHandler {
+            audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed)
+        }
+
+        // 封面动态取色:从封面提取主色作为本界面主题
+        val seedColor = rememberImageSeedColor(
+            imageLoader = imageLoader,
+            data = uiState.coverPath,
+            requestKey = listOf(uiState.coverPath, uiState.sourceOrigin),
+        ) {
+            extras[CoverExtras.SourceOrigin] = uiState.sourceOrigin
+        }
+        val themeOverride = rememberThemeOverride(seedColor)
+        ProvideThemeOverride(themeOverride) {
+            AudioPlayScreenContent(
+                state = uiState,
+                onIntent = audioPlayViewModel::onIntent,
+                onBack = { audioPlayViewModel.onIntent(AudioPlayIntent.BackPressed) },
+            )
+        }
+    }
+
     entry<MainRouteSearchContent> { route ->
         val viewModel = koinViewModel<SearchContentViewModel>(
             key = "SearchContent:${route.bookUrl}",
@@ -956,6 +1098,14 @@ fun MainActivity.mainEntryProvider(
                     )
                 )
             },
+            onOpenAudioPlay = { bookUrl, inBookshelf ->
+                onNavigateToRoute(
+                    MainRouteAudioPlay(
+                        bookUrl = bookUrl,
+                        inBookshelf = inBookshelf,
+                    )
+                )
+            },
             onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath ->
                 onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl, origin, coverPath))
             },

+ 7 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -141,6 +141,12 @@ data class MainRouteReadManga(
     val chapterChanged: Boolean = false,
 ) : MainRoute
 
+@Serializable
+data class MainRouteAudioPlay(
+    val bookUrl: String? = null,
+    val inBookshelf: Boolean = true,
+) : MainRoute
+
 @Serializable
 data class MainRouteSearch(
     val key: String?,
@@ -264,6 +270,7 @@ object MainRouteConst {
     const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage"
     const val ROUTE_READ_BOOK = "book/read"
     const val ROUTE_READ_MANGA = "book/read/manga"
+    const val ROUTE_AUDIO_PLAY = "book/read/audio"
     const val ROUTE_SEARCH = "search"
     const val ROUTE_SEARCH_CONTENT = "book/searchContent"
     const val ROUTE_BOOK_INFO = "book/info"

+ 24 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -121,6 +121,26 @@ object MainNavigator {
                 }
             }
 
+            is MainRouteAudioPlay -> {
+                // 播放器为单例语义:已在栈上(如通知栏再次进入)则替换,避免叠加多个播放界面
+                val existingAudioIndex = backStack.indexOfLast { it is MainRouteAudioPlay }
+                if (existingAudioIndex >= 0) {
+                    while (backStack.lastIndex > existingAudioIndex) {
+                        backStack.removeAt(backStack.lastIndex)
+                    }
+                    backStack[existingAudioIndex] = route
+                } else if (
+                    currentRoute == MainRouteHome ||
+                    currentRoute is MainRouteBookInfo
+                ) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
+
             is MainRouteSearchContent -> {
                 backStack.add(route)
             }
@@ -429,6 +449,10 @@ object MainNavigator {
                     false,
                 ) == true,
             )
+            MainRouteConst.ROUTE_AUDIO_PLAY -> MainRouteAudioPlay(
+                bookUrl = intent?.getStringExtra(MainIntent.EXTRA_BOOK_URL),
+                inBookshelf = intent?.getBooleanExtra(MainIntent.EXTRA_IN_BOOKSHELF, true) != false,
+            )
             MainRouteConst.ROUTE_SEARCH -> MainRouteSearch(
                 key = intent?.getStringExtra(MainIntent.EXTRA_SEARCH_KEY),
                 scopeRaw = intent?.getStringExtra(MainIntent.EXTRA_SEARCH_SCOPE)

+ 12 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt

@@ -1,6 +1,10 @@
 package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkHorizontally
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
@@ -10,6 +14,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.vector.ImageVector
@@ -62,7 +67,13 @@ internal fun AnimatedActionButtonCore(
             contentColor
         )
 
-        AnimatedVisibility(visible = showText) {
+        // 用尺寸动画而非默认的整体缩放:文字消失/出现时宽度连续变化,避免
+        // 内容移除瞬间按钮宽度突变导致的布局顿挫
+        AnimatedVisibility(
+            visible = showText,
+            enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(),
+            exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut(),
+        ) {
             text(
                 if (lastCheckedState) activeText else inactiveText,
                 Modifier.padding(start = textStartPadding),

+ 5 - 6
app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt

@@ -237,11 +237,7 @@ internal fun SeriesAnimatedButtonContent(
     val hasText = text != null
     Row(
         modifier = Modifier.padding(if (hasText) padding else PaddingValues(0.dp)),
-        horizontalArrangement = Arrangement.spacedBy(
-            if (hasText) spacing else 0.dp,
-            Alignment.CenterHorizontally
-        ),
-        verticalAlignment = Alignment.CenterVertically
+        verticalAlignment = Alignment.CenterVertically,
     ) {
         AppIcon(
             imageVector = icon,
@@ -256,7 +252,10 @@ internal fun SeriesAnimatedButtonContent(
                     style = textStyle,
                     color = contentColor,
                     maxLines = 1,
-                    softWrap = false
+                    softWrap = false,
+                    // 间距并入动画内容,随文字一起伸缩;Row 级 spacedBy 间距不参与
+                    // 动画,会在文字移除瞬间突变造成顿挫
+                    modifier = Modifier.padding(start = spacing)
                 )
             }
         }

+ 86 - 0
app/src/main/java/io/legado/app/ui/widget/components/player/PlayerAdjustmentSlider.kt

@@ -0,0 +1,86 @@
+package io.legado.app.ui.widget.components.player
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppSlider
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.text.AppText
+
+/**
+ * 播放器通用的调节滑块卡片(倍速/定时/增益等):标题 + 数值标签 + 滑块 + 两端说明。
+ */
+@Composable
+fun PlayerAdjustmentSlider(
+    title: String,
+    value: Float,
+    valueLabel: String,
+    startLabel: String,
+    endLabel: String,
+    onValueChange: (Float) -> Unit,
+    onValueChangeFinished: () -> Unit,
+    valueRange: ClosedFloatingPointRange<Float>,
+    steps: Int,
+    enabled: Boolean = true,
+) {
+    NormalCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        cornerRadius = 24.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+    ) {
+        Column(
+            modifier = Modifier.padding(all = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            Row(
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                AppText(
+                    modifier = Modifier.weight(1f),
+                    text = title,
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                )
+                TextCard(
+                    text = valueLabel,
+                )
+            }
+
+            AppSlider(
+                value = value,
+                onValueChange = onValueChange,
+                onValueChangeFinished = onValueChangeFinished,
+                valueRange = valueRange,
+                steps = steps,
+                enabled = enabled,
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween,
+            ) {
+                AppText(
+                    text = startLabel,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                AppText(
+                    text = endLabel,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}

+ 151 - 0
app/src/main/java/io/legado/app/ui/widget/components/player/PlayerBackground.kt

@@ -0,0 +1,151 @@
+package io.legado.app.ui.widget.components.player
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.effect.BgEffectBackground
+import io.legado.app.ui.widget.components.effect.BgEffectConfig
+import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
+import top.yukonga.miuix.kmp.blur.isRuntimeShaderSupported
+
+/**
+ * 播放器通用背景:纯色 / 封面模糊 / 流动光 / 透明 四种模式。
+ */
+@Composable
+fun PlayerBackground(
+    name: String?,
+    author: String?,
+    path: String?,
+    sourceOrigin: String?,
+    bgMode: Int,
+    modifier: Modifier = Modifier,
+) {
+    when (bgMode) {
+        ReadAloudBgMode.Blur -> {
+            CoverBlurBackdrop(
+                name, author, path, sourceOrigin,
+                modifier = modifier,
+            )
+        }
+
+        ReadAloudBgMode.FlowingLight -> {
+            val shaderSupported = remember { isRuntimeShaderSupported() }
+            if (shaderSupported) {
+                val coverPreset = rememberCoverDerivedPreset()
+                Box(modifier.fillMaxSize()) {
+                    CoverBlurBackdrop(
+                        name, author, path, sourceOrigin,
+                        blurRadius = 64.dp,
+                    )
+                    BgEffectBackground(
+                        dynamicBackground = true,
+                        isOs3Effect = true,
+                        isFullSize = true,
+                        drawSurface = false,
+                        customPreset = coverPreset,
+                        modifier = Modifier.fillMaxSize(),
+                        alpha = { 0.5f },
+                    ) {
+                        Box(Modifier.fillMaxSize())
+                    }
+                }
+            } else {
+                CoverBlurBackdrop(
+                    name, author, path, sourceOrigin,
+                    modifier = modifier,
+                )
+            }
+        }
+
+        ReadAloudBgMode.Transparent -> {
+            Box(modifier = modifier.fillMaxSize())
+        }
+
+        else -> {
+            Box(
+                modifier = modifier
+                    .fillMaxSize()
+                    .background(LegadoTheme.colorScheme.surface),
+            )
+        }
+    }
+}
+
+@Composable
+private fun rememberCoverDerivedPreset(): BgEffectConfig.Config {
+    val primary = LegadoTheme.colorScheme.primary
+    val surface = LegadoTheme.colorScheme.secondaryContainer
+    val tertiary = LegadoTheme.colorScheme.secondary
+    val isDark = LegadoTheme.isDark
+
+    return remember(primary, surface, tertiary, isDark) {
+        val darken = if (isDark) 0.68f else 0.88f
+        val p = primary.copy(
+            red = primary.red * darken,
+            green = primary.green * darken,
+            blue = primary.blue * darken,
+        ).toShaderColor()
+        val s = surface.copy(
+            red = surface.red * darken,
+            green = surface.green * darken,
+            blue = surface.blue * darken,
+        ).toShaderColor()
+        val t = tertiary.copy(
+            red = tertiary.red * darken,
+            green = tertiary.green * darken,
+            blue = tertiary.blue * darken,
+        ).toShaderColor()
+
+        val m = floatArrayOf(
+            (p[0] + t[0]) / 2f,
+            (p[1] + t[1]) / 2f,
+            (p[2] + t[2]) / 2f,
+            1.0f,
+        )
+
+        fun stage(a: FloatArray, b: FloatArray, c: FloatArray, d: FloatArray): FloatArray =
+            floatArrayOf(
+                a[0], a[1], a[2], a[3],
+                b[0], b[1], b[2], b[3],
+                c[0], c[1], c[2], c[3],
+                d[0], d[1], d[2], d[3],
+            )
+
+        BgEffectConfig.Config(
+            points = floatArrayOf(
+                0.8f, 0.2f, 1.0f,
+                0.8f, 0.9f, 1.0f,
+                0.2f, 0.9f, 1.0f,
+                0.2f, 0.2f, 1.0f,
+            ),
+            colors1 = stage(p, t, s, m),
+            colors2 = stage(t, s, m, p),
+            colors3 = stage(s, m, p, t),
+            colorInterpPeriod = 96.0f,
+            lightOffset = if (isDark) 0.0f else 0.1f,
+            saturateOffset = if (isDark) 0.17f else 0.2f,
+            pointOffset = if (isDark) 0.4f else 0.2f,
+        )
+    }
+}
+
+private fun Color.toShaderColor(): FloatArray =
+    floatArrayOf(red, green, blue, alpha)
+
+@Composable
+fun playerBgModeLabel(mode: Int): String = when (mode) {
+    ReadAloudBgMode.Solid -> stringResource(R.string.read_aloud_bg_solid)
+    ReadAloudBgMode.Blur -> stringResource(R.string.read_aloud_bg_blur)
+    ReadAloudBgMode.FlowingLight -> stringResource(R.string.read_aloud_bg_flowing_light)
+    ReadAloudBgMode.Transparent -> stringResource(R.string.read_aloud_bg_transparent)
+    else -> stringResource(R.string.read_aloud_bg_blur)
+}

+ 124 - 0
app/src/main/java/io/legado/app/ui/widget/components/player/PlayerProgressSlider.kt

@@ -0,0 +1,124 @@
+package io.legado.app.ui.widget.components.player
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+
+/**
+ * 播放器通用的进度条:拖动时放大轨道、松手提交进度。
+ */
+@Composable
+fun PlayerProgressSlider(
+    value: Float,
+    onValueChange: (Float) -> Unit,
+    valueRange: ClosedFloatingPointRange<Float>,
+    modifier: Modifier = Modifier
+) {
+    val rangeLength = (valueRange.endInclusive - valueRange.start).coerceAtLeast(1f)
+    var isDragging by remember { mutableStateOf(false) }
+    var widthPx by remember { mutableFloatStateOf(1f) }
+    var previewFraction by remember(valueRange) {
+        mutableFloatStateOf(((value - valueRange.start) / rangeLength).coerceIn(0f, 1f))
+    }
+    LaunchedEffect(value, valueRange) {
+        if (!isDragging) {
+            previewFraction = ((value - valueRange.start) / rangeLength).coerceIn(0f, 1f)
+        }
+    }
+    val trackScale by animateFloatAsState(
+        targetValue = if (isDragging) 1.35f else 1f,
+        animationSpec = tween(durationMillis = 200),
+        label = "player_progress_track_scale",
+    )
+    val activeColor = LegadoTheme.colorScheme.onSurface
+    val inactiveColor = LegadoTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+    Canvas(
+        modifier = modifier
+            .fillMaxWidth()
+            .height(18.dp)
+            .graphicsLayer { scaleY = trackScale }
+            .onSizeChanged { widthPx = it.width.toFloat().coerceAtLeast(1f) }
+            .pointerInput(valueRange, widthPx) {
+                fun updatePreview(x: Float) {
+                    previewFraction = (x / widthPx).coerceIn(0f, 1f)
+                }
+                detectHorizontalDragGestures(
+                    onDragStart = { offset ->
+                        isDragging = true
+                        updatePreview(offset.x)
+                    },
+                    onDragEnd = {
+                        isDragging = false
+                        onValueChange(valueRange.start + previewFraction * rangeLength)
+                    },
+                    onDragCancel = { isDragging = false },
+                    onHorizontalDrag = { change, _ ->
+                        change.consume()
+                        updatePreview(change.position.x)
+                    },
+                )
+            },
+    ) {
+        val centerY = size.height / 2f
+        val progressX = size.width * previewFraction
+        val trackHeight = 2.dp.toPx()
+        val trackRadius = trackHeight / 2f
+        val trackClip = Path().apply {
+            addRoundRect(
+                RoundRect(
+                    left = 0f,
+                    top = centerY - trackRadius,
+                    right = size.width,
+                    bottom = centerY + trackRadius,
+                    cornerRadius = CornerRadius(trackRadius),
+                )
+            )
+        }
+        val tickSpacing = 4.dp.toPx()
+        val tickHeight = 2.dp.toPx()
+        clipPath(trackClip) {
+            var tickX = 0f
+            while (tickX <= size.width) {
+                drawLine(
+                    color = inactiveColor,
+                    start = Offset(tickX, centerY - tickHeight / 2f),
+                    end = Offset(tickX, centerY + tickHeight / 2f),
+                    strokeWidth = 1.5.dp.toPx(),
+                    cap = StrokeCap.Round,
+                )
+                tickX += tickSpacing
+            }
+            if (progressX > 0f) {
+                drawLine(
+                    color = activeColor,
+                    start = Offset(0f, centerY),
+                    end = Offset(progressX, centerY),
+                    strokeWidth = trackHeight,
+                    cap = StrokeCap.Butt,
+                )
+            }
+        }
+    }
+}

+ 171 - 0
app/src/main/java/io/legado/app/ui/widget/components/player/PlayerTocPage.kt

@@ -0,0 +1,171 @@
+package io.legado.app.ui.widget.components.player
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.collections.immutable.ImmutableList
+
+/** 播放器目录/章节列表的通用条目模型。 */
+@Stable
+data class PlayerChapterUi(
+    val index: Int,
+    val title: String,
+    val isVolume: Boolean,
+    val tocLevel: Int,
+)
+
+/**
+ * 播放器通用的卷/章节列表页:卷名标题 + 章节卡片,自动滚动到当前章节。
+ */
+@Composable
+fun PlayerTocPage(
+    chapters: ImmutableList<PlayerChapterUi>,
+    currentIndex: Int,
+    isPaused: Boolean,
+    onSelect: (Int) -> Unit,
+    contentPadding: PaddingValues,
+    modifier: Modifier = Modifier,
+) {
+    val listState = rememberLazyListState()
+    // 只在实际章节变化时滚动一次:chapters 列表实例可能随播放进度事件重建,
+    // 若每次都在 LaunchedEffect 里滚动,用户浏览目录时会被反复拽回当前章节
+    var lastScrollTarget by remember { mutableIntStateOf(-1) }
+    LaunchedEffect(currentIndex, chapters) {
+        val currentItem = chapters.indexOfFirst {
+            !it.isVolume && it.index == currentIndex
+        }
+        if (currentItem >= 0) {
+            val target = (currentItem - 2).coerceAtLeast(0)
+            if (target != lastScrollTarget) {
+                lastScrollTarget = target
+                listState.scrollToItem(target)
+            }
+        }
+    }
+
+    LazyColumn(
+        state = listState,
+        modifier = modifier.fillMaxSize(),
+        contentPadding = PaddingValues(
+            start = 16.dp,
+            top = contentPadding.calculateTopPadding() + 12.dp,
+            end = 16.dp,
+            bottom = contentPadding.calculateBottomPadding() + 12.dp,
+        ),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        if (chapters.isEmpty()) {
+            item {
+                AppText(
+                    text = stringResource(R.string.chapter_list_empty),
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 32.dp),
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    textAlign = TextAlign.Center,
+                )
+            }
+        } else {
+            items(
+                items = chapters,
+                key = { chapter ->
+                    if (chapter.isVolume) "player-volume-${chapter.index}"
+                    else "player-chapter-${chapter.index}"
+                },
+                contentType = { chapter -> if (chapter.isVolume) "volume" else "chapter" },
+            ) { chapter ->
+                if (chapter.isVolume) {
+                    AppText(
+                        text = chapter.title,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(
+                                start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp,
+                                top = 12.dp,
+                                bottom = 4.dp,
+                            ),
+                        style = LegadoTheme.typography.titleSmallEmphasized,
+                        color = LegadoTheme.colorScheme.primary,
+                        maxLines = 1,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                } else {
+                    val isCurrent = chapter.index == currentIndex
+                    NormalCard(
+                        onClick = { onSelect(chapter.index) },
+                        cornerRadius = 12.dp,
+                        containerColor = if (isCurrent) {
+                            LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+                        } else {
+                            LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0f)
+                        },
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp),
+                    ) {
+                        Row(
+                            modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
+                            verticalAlignment = Alignment.CenterVertically,
+                        ) {
+                            if (isCurrent) {
+                                AppIcon(
+                                    imageVector = if (isPaused) {
+                                        Icons.Default.PlayArrow
+                                    } else {
+                                        Icons.Default.Pause
+                                    },
+                                    contentDescription = null,
+                                    modifier = Modifier
+                                        .padding(end = 8.dp)
+                                        .size(16.dp),
+                                )
+                            }
+                            Column(modifier = Modifier.weight(1f)) {
+                                AppText(
+                                    text = chapter.title,
+                                    style = if (isCurrent) {
+                                        LegadoTheme.typography.bodyMediumEmphasized
+                                    } else {
+                                        LegadoTheme.typography.bodyMedium
+                                    },
+                                    maxLines = 2,
+                                    overflow = TextOverflow.Ellipsis,
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 2 - 3
app/src/main/java/io/legado/app/utils/ContextExtensions.kt

@@ -44,7 +44,6 @@ import io.legado.app.help.book.isImage
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.SettingsWriter
-import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.bookshelf.BookShelfItem
@@ -68,7 +67,7 @@ fun Context.startActivityForBook(
     configIntent: Intent.() -> Unit = {},
 ) {
     val intent = when {
-        book.isAudio -> Intent(this, AudioPlayActivity::class.java)
+        book.isAudio -> MainActivity.createAudioPlayIntent(this, book.bookUrl)
         book.isImage && ReadMangaConfig.showMangaUi ->
             MainActivity.createReadMangaIntent(this, book.bookUrl)
 
@@ -87,7 +86,7 @@ fun Context.startActivityForBook(
     configIntent: Intent.() -> Unit = {},
 ) {
     val intent = when {
-        book.isAudio -> Intent(this, AudioPlayActivity::class.java)
+        book.isAudio -> MainActivity.createAudioPlayIntent(this, book.bookUrl)
         book.isImage && ReadMangaConfig.showMangaUi ->
             MainActivity.createReadMangaIntent(this, book.bookUrl)
 

+ 1 - 2
app/src/main/java/io/legado/app/utils/FragmentExtensions.kt

@@ -18,7 +18,6 @@ import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
-import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.widget.dialog.TextDialog
 
@@ -55,7 +54,7 @@ fun Fragment.startActivityForBook(
     configIntent: Intent.() -> Unit = {},
 ) {
     val intent = when {
-        book.isAudio -> Intent(requireActivity(), AudioPlayActivity::class.java)
+        book.isAudio -> MainActivity.createAudioPlayIntent(requireActivity(), book.bookUrl)
         !book.isLocal && book.isImage && ReadMangaConfig.showMangaUi ->
             MainActivity.createReadMangaIntent(requireActivity(), book.bookUrl)
 

+ 0 - 11
app/src/main/res/drawable/ic_play_mode_list_end_stop.xml

@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="?attr/colorControlNormal"
-    android:viewportWidth="960"
-    android:viewportHeight="960">
-    <path
-        android:fillColor="@android:color/white"
-        android:pathData="M460,600L460,420L400,420L400,360L520,360L520,600L460,600ZM280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
-</vector>

+ 0 - 11
app/src/main/res/drawable/ic_play_mode_list_loop.xml

@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="24dp"
-    android:height="24dp"
-    android:tint="?attr/colorControlNormal"
-    android:viewportWidth="960"
-    android:viewportHeight="960">
-    <path
-        android:fillColor="@android:color/white"
-        android:pathData="M280,880L120,720L280,560L336,618L274,680L680,680L680,520L760,520L760,760L274,760L336,822L280,880ZM200,440L200,200L686,200L624,138L680,80L840,240L680,400L624,342L686,280L280,280L280,440L200,440Z" />
-</vector>

+ 0 - 417
app/src/main/res/layout/activity_audio_play.xml

@@ -1,417 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical"
-    tools:context=".ui.book.audio.AudioPlayActivity">
-
-    <ImageView
-        android:id="@+id/iv_bg"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:scaleType="centerCrop"
-        tools:ignore="ContentDescription,ImageContrastCheck" />
-
-    <View
-        android:id="@+id/vw_bg2"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:alpha="0.80"
-        android:background="?attr/colorSurfaceContainer" />
-
-    <com.google.android.material.appbar.AppBarLayout
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:fitsSystemWindows="true"
-        app:layout_constraintTop_toTopOf="parent" >
-        <com.google.android.material.appbar.MaterialToolbar
-            android:id="@+id/tool_bar"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:title=" "
-            app:navigationIcon="@drawable/ic_arrow_back"/>
-    </com.google.android.material.appbar.AppBarLayout>
-
-    <com.google.android.material.card.MaterialCardView
-        android:id="@+id/cd_cover"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        app:cardElevation="16dp"
-        android:layout_marginHorizontal="32dp"
-        android:layout_marginBottom="16dp"
-        app:layout_constraintDimensionRatio="1:1"
-        app:layout_constraintBottom_toTopOf="@+id/ll_title"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/title_bar"
-        app:strokeWidth="0dp">
-
-        <ImageView
-            android:id="@+id/iv_cover"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:contentDescription="@string/img_cover"
-            android:scaleType="centerCrop" />
-    </com.google.android.material.card.MaterialCardView>
-
-    <com.google.android.material.card.MaterialCardView
-        android:id="@+id/cd_timer"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="8dp"
-        android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainer"
-        app:cardCornerRadius="4dp"
-        app:cardElevation="16dp"
-        app:layout_constraintStart_toStartOf="@id/cd_cover"
-        app:layout_constraintTop_toTopOf="@id/cd_cover"
-        app:strokeWidth="0dp">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:gravity="center_vertical"
-            android:paddingHorizontal="4dp"
-            android:paddingVertical="2dp"
-            tools:ignore="UseCompoundDrawables">
-
-            <ImageView
-                android:id="@+id/btn_timer"
-                android:layout_width="12dp"
-                android:layout_height="12dp"
-                android:layout_marginEnd="4dp"
-                android:importantForAccessibility="no"
-                android:src="@drawable/ic_timer_black_24dp" />
-
-            <TextView
-                android:id="@+id/tv_timer"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/text"
-                android:textSize="10sp"
-                android:textStyle="bold"
-                tools:ignore="SmallSp" />
-        </LinearLayout>
-
-    </com.google.android.material.card.MaterialCardView>
-
-    <com.google.android.material.card.MaterialCardView
-        android:id="@+id/cd_speed"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_margin="8dp"
-        android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainer"
-        app:cardCornerRadius="4dp"
-        app:cardElevation="16dp"
-        app:layout_constraintStart_toEndOf="@id/cd_timer"
-        app:layout_constraintTop_toTopOf="@id/cd_cover"
-        app:strokeWidth="0dp">
-
-        <TextView
-            android:id="@+id/tv_speed"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical"
-            android:paddingHorizontal="4dp"
-            android:paddingVertical="2dp"
-            android:text="@string/text"
-            android:textSize="10sp"
-            android:textStyle="bold"
-            tools:ignore="SmallSp" />
-    </com.google.android.material.card.MaterialCardView>
-
-    <LinearLayout
-        android:id="@+id/ll_title"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical"
-        android:layout_marginHorizontal="28dp"
-        app:layout_constraintTop_toBottomOf="@+id/cd_cover"
-        app:layout_constraintBottom_toTopOf="@+id/player_progress">
-
-        <TextView
-            android:id="@+id/tv_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginBottom="4dp"
-            android:gravity="center"
-            android:scrollbars="none"
-            android:textSize="20sp"
-            android:textStyle="bold"
-            app:layout_constraintBottom_toTopOf="@+id/tv_sub_title"
-            tools:text="@string/text" />
-
-        <TextView
-            android:id="@+id/tv_sub_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center"
-            android:scrollbars="none"
-            android:textColor="?attr/colorOnSurfaceVariant"
-            android:textSize="14sp"
-            android:textStyle="bold"
-            app:layout_constraintBottom_toTopOf="@+id/player_progress"
-            tools:text="@string/text" />
-
-    </LinearLayout>
-
-
-
-
-
-    <TextView
-        android:id="@+id/tv_dur_time"
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="32dp"
-        android:layout_marginBottom="8dp"
-        android:text="@string/text"
-        android:textStyle="bold"
-        android:textColor="?attr/colorPrimary"
-        app:layout_constraintBottom_toTopOf="@id/ll_play_menu"
-        app:layout_constraintStart_toStartOf="parent"
-        tools:ignore="HardcodedText,RtlSymmetry,TextContrastCheck" />
-
-    <com.google.android.material.slider.Slider
-        android:id="@+id/player_progress"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center_vertical"
-        android:layout_marginHorizontal="16dp"
-        android:value="0"
-        android:valueFrom="0"
-        android:valueTo="100000"
-        app:tickColorInactive="@android:color/transparent"
-        app:layout_constraintBottom_toTopOf="@id/tv_all_time"
-        app:thumbElevation="0dp"
-        app:thumbHeight="12dp"
-        app:thumbWidth="4dp"
-        app:trackHeight="2dp" />
-
-    <TextView
-        android:id="@+id/tv_all_time"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginEnd="32dp"
-        android:layout_marginBottom="8dp"
-        android:text="@string/text"
-        android:textStyle="bold"
-        android:textColor="?attr/colorPrimary"
-        app:layout_constraintBottom_toTopOf="@id/ll_play_menu"
-        app:layout_constraintEnd_toEndOf="parent"
-        tools:ignore="HardcodedText,TextContrastCheck" />
-
-    <LinearLayout
-        android:id="@+id/ll_play_menu"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="16dp"
-        android:gravity="center_vertical"
-        android:orientation="horizontal"
-        android:paddingHorizontal="6dp"
-        app:layout_constraintBottom_toTopOf="@id/ll_sub_menu">
-
-        <View
-            android:layout_width="0dp"
-            android:layout_height="1dp"
-            android:layout_weight="2" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/iv_skip_previous"
-            style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:contentDescription="@string/skip_previous"
-            app:icon="@drawable/ic_skip_previous"
-            app:iconGravity="textStart"
-            app:iconSize="32dp"
-            app:strokeWidth="0dp"
-            tools:ignore="ImageContrastCheck" />
-
-        <View
-            android:layout_width="0dp"
-            android:layout_height="1dp"
-            android:layout_weight="1" />
-
-        <androidx.constraintlayout.widget.ConstraintLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/fab_play_stop"
-                style="@style/Widget.Material3Expressive.Button.IconButton"
-                android:layout_width="80dp"
-                android:contentDescription="@string/audio_play"
-                android:layout_height="80dp"
-                app:backgroundTint="?attr/colorSecondaryContainer"
-                app:icon="@drawable/play_anim"
-                app:iconGravity="textStart"
-                app:iconPadding="0dp"
-                app:iconSize="42dp"
-                app:iconTint="?attr/colorOnSecondaryContainer"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-            <com.google.android.material.loadingindicator.LoadingIndicator
-                android:id="@+id/progress_loading"
-                android:layout_width="0dp"
-                android:layout_height="0dp"
-                android:visibility="invisible"
-                app:layout_constraintWidth="parent"
-                app:layout_constraintHeight="parent"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toTopOf="parent" />
-
-        </androidx.constraintlayout.widget.ConstraintLayout>
-
-        <View
-            android:layout_width="0dp"
-            android:layout_height="1dp"
-            android:layout_weight="1" />
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/iv_skip_next"
-            style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:contentDescription="@string/skip_next"
-            app:icon="@drawable/ic_skip_next"
-            app:iconGravity="textStart"
-            app:iconSize="32dp"
-            app:strokeWidth="0dp"
-            tools:ignore="ImageContrastCheck" />
-
-        <View
-            android:layout_width="0dp"
-            android:layout_height="1dp"
-            android:layout_weight="2" />
-
-    </LinearLayout>
-
-    <LinearLayout
-        android:id="@+id/ll_sub_menu"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginBottom="16dp"
-        android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="parent">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal">
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="1dp"
-                android:layout_weight="3" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_play_mode"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:contentDescription="@string/play_mode"
-                app:icon="@drawable/ic_play_mode_list_end_stop"
-                app:iconGravity="textStart"
-                app:iconSize="20dp"
-                app:strokeWidth="0dp" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="1dp"
-                android:layout_weight="1" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_timer"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:contentDescription="@string/set_timer"
-                app:icon="@drawable/ic_timer_black_24dp"
-                app:iconGravity="textStart"
-                app:iconSize="20dp"
-                app:strokeWidth="0dp" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="1dp"
-                android:layout_weight="1" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_chapter"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:contentDescription="@string/chapter_list"
-                app:icon="@drawable/ic_chapter_list"
-                app:iconGravity="textStart"
-                app:iconSize="20dp"
-                app:strokeWidth="0dp" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="1dp"
-                android:layout_weight="1" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_fast_forward"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Outlined"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:contentDescription="@string/skip_next"
-                app:icon="@drawable/ic_speed"
-                app:iconGravity="textStart"
-                app:strokeWidth="0dp"
-                tools:ignore="ImageContrastCheck" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="1dp"
-                android:layout_weight="3" />
-        </LinearLayout>
-
-        <LinearLayout
-            android:id="@+id/ll_set"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginHorizontal="16dp"
-            android:visibility="gone"
-            app:layout_constraintBottom_toBottomOf="parent">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_reset"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:alpha="0.8"
-                android:contentDescription="@string/restore"
-                app:icon="@drawable/ic_restore"
-                app:iconGravity="textStart"
-                tools:ignore="ImageContrastCheck" />
-
-            <com.google.android.material.slider.Slider
-                android:id="@+id/setting_slider"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                app:tickColorInactive="@android:color/transparent"
-                android:layout_gravity="center_vertical"
-                android:value="0"
-                android:valueFrom="0"
-                android:valueTo="1" />
-        </LinearLayout>
-
-
-    </LinearLayout>
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 0 - 45
app/src/main/res/menu/audio_play.xml

@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:ignore="AlwaysShowAction">
-
-    <item
-        android:id="@+id/menu_change_source"
-        android:icon="@drawable/ic_exchange"
-        android:title="@string/change_origin"
-        app:showAsAction="always" />
-
-    <item
-        android:id="@+id/menu_login"
-        android:title="@string/login"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_copy_audio_url"
-        android:title="@string/copy_play_url"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_edit_source"
-        android:title="@string/edit_book_source"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_wake_lock"
-        android:title="@string/audio_play_wake_lock"
-        android:checkable="true"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_media_control"
-        android:title="@string/system_media_control_compatibility_change"
-        android:checkable="true"
-        app:showAsAction="never" />
-
-    <item
-        android:id="@+id/menu_log"
-        android:title="@string/log"
-        app:showAsAction="never" />
-
-</menu>

+ 12 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -193,6 +193,18 @@
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">点击打开播放界面</string>
     <string name="audio_pause">播放暂停</string>
+    <string name="audio_play_speed">播放速度</string>
+    <string name="audio_play_mode_sequence">顺序播放</string>
+    <string name="audio_play_mode_random">随机播放</string>
+    <string name="audio_play_mode_loop">列表循环</string>
+    <string name="audio_play_mode_single_loop">单曲循环</string>
+    <string name="audio_play_skip_credits">跳过片头片尾</string>
+    <string name="audio_play_open_credits">片头</string>
+    <string name="audio_play_close_credits">片尾</string>
+    <string name="audio_play_gain">音频增益</string>
+    <string name="seconds_m">%d 秒</string>
+    <string name="more">更多</string>
+    <string name="book_add_failed">添加书籍失败</string>
     <string name="text_return">返回</string>
     <string name="refresh">刷新</string>
     <string name="pull_to_refresh">下拉刷新</string>

+ 12 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -148,6 +148,18 @@
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">點擊打開播放介面</string>
     <string name="audio_pause">播放暫停</string>
+    <string name="audio_play_speed">播放速度</string>
+    <string name="audio_play_mode_sequence">順序播放</string>
+    <string name="audio_play_mode_random">隨機播放</string>
+    <string name="audio_play_mode_loop">列表循環</string>
+    <string name="audio_play_mode_single_loop">單曲循環</string>
+    <string name="audio_play_skip_credits">跳過片頭片尾</string>
+    <string name="audio_play_open_credits">片頭</string>
+    <string name="audio_play_close_credits">片尾</string>
+    <string name="audio_play_gain">音頻增益</string>
+    <string name="seconds_m">%d 秒</string>
+    <string name="more">更多</string>
+    <string name="book_add_failed">添加書籍失敗</string>
     <string name="text_return">返回</string>
     <string name="refresh">刷新</string>
     <string name="start">開始</string>

+ 12 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -150,6 +150,18 @@
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">點擊打開播放介面</string>
     <string name="audio_pause">播放暫停</string>
+    <string name="audio_play_speed">播放速度</string>
+    <string name="audio_play_mode_sequence">順序播放</string>
+    <string name="audio_play_mode_random">隨機播放</string>
+    <string name="audio_play_mode_loop">列表循環</string>
+    <string name="audio_play_mode_single_loop">單曲循環</string>
+    <string name="audio_play_skip_credits">跳過片頭片尾</string>
+    <string name="audio_play_open_credits">片頭</string>
+    <string name="audio_play_close_credits">片尾</string>
+    <string name="audio_play_gain">音頻增益</string>
+    <string name="seconds_m">%d 秒</string>
+    <string name="more">更多</string>
+    <string name="book_add_failed">添加書籍失敗</string>
     <string name="text_return">返回</string>
     <string name="refresh">重新整理</string>
     <string name="start">開始</string>

+ 12 - 0
app/src/main/res/values/strings.xml

@@ -223,6 +223,18 @@
     <string name="audio_play_t">Playing</string>
     <string name="audio_play_s">Click to open the Playing</string>
     <string name="audio_pause">Pause</string>
+    <string name="audio_play_speed">Playback speed</string>
+    <string name="audio_play_mode_sequence">Sequence play</string>
+    <string name="audio_play_mode_random">Random play</string>
+    <string name="audio_play_mode_loop">Loop list</string>
+    <string name="audio_play_mode_single_loop">Loop single</string>
+    <string name="audio_play_skip_credits">Skip intro/outro</string>
+    <string name="audio_play_open_credits">Intro</string>
+    <string name="audio_play_close_credits">Outro</string>
+    <string name="audio_play_gain">Audio gain</string>
+    <string name="seconds_m">%d s</string>
+    <string name="more">More</string>
+    <string name="book_add_failed">Failed to add book</string>
     <string name="text_return">Back</string>
     <string name="refresh">Refresh</string>
     <string name="pull_to_refresh">Pull to refresh</string>

+ 0 - 1
build.gradle.kts

@@ -262,7 +262,6 @@ val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
             "io/legado/app/ui/association/AddToBookshelfDialog.kt" to 5,
             "io/legado/app/ui/association/ImportReplaceRuleDialog.kt" to 1,
             "io/legado/app/ui/association/ImportRssSourceDialog.kt" to 1,
-            "io/legado/app/ui/book/audio/AudioPlayActivity.kt" to 1,
             "io/legado/app/ui/book/bookmark/BookmarkDialog.kt" to 2,
             "io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt" to 1,
             "io/legado/app/ui/book/group/GroupManageDialog.kt" to 2,