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

fix: 听书系统媒体播放器进度条随朗读推进 (#2042)

* fix: 听书系统媒体播放器进度条随朗读推进 (#2035)

- MediaSession 进度改为按字符进度和语速估算的毫秒时间, 不再使用固定的 100 秒假时间轴
- 播放期间每秒推送一次 PlaybackState, 进度条随朗读推进而不是停留在 0:00
- 元数据设置按章节字数估算的总时长, 语速变化时同步刷新

* fix: 修复听书媒体播放器进度条来回跳变 (#2035)

- 播放中进度保持单调: 字符估算值领先时跟随估算值, 落后时按墙钟推进, 不再每秒重复推送同一估算值导致进度条 05/06 来回跳变
- 暂停时冻结在系统插值的显示位置, 恢复时从冻结位置继续, 避免跳变
- 进度回退(上一段/上一章)时重置单调锚点, 进度条正常跟随回退
lingran7031 2 месяцев назад
Родитель
Сommit
074a8cd8d6

+ 122 - 17
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -14,6 +14,7 @@ import android.media.AudioManager
 import android.net.wifi.WifiManager
 import android.os.Bundle
 import android.os.PowerManager
+import android.os.SystemClock
 import android.support.v4.media.MediaMetadataCompat
 import android.support.v4.media.session.MediaSessionCompat
 import android.support.v4.media.session.PlaybackStateCompat
@@ -116,7 +117,11 @@ abstract class BaseReadAloudService : BaseService(),
 
         private const val TAG = "BaseReadAloudService"
         private const val ACTION_ADD_TIMER = "io.legado.app.action.ADD_READ_ALOUD_TIMER"
-        private const val MEDIA_PROGRESS_DURATION_MS = 100_000L
+
+        /**
+         * 语速 1.0x 时的每秒朗读字数估算值, 用于把字符进度换算成媒体播放器时间轴
+         */
+        private const val ESTIMATED_CHARS_PER_SECOND = 4f
 
         private const val READ_ALOUD_MEDIA_SESSION_ACTIONS =
             (PlaybackStateCompat.ACTION_PLAY
@@ -169,6 +174,10 @@ abstract class BaseReadAloudService : BaseService(),
     private var registeredPhoneStateListener = false
     private var dsJob: Job? = null
     private var upNotificationJob: Job? = null
+    private var upMediaProgressJob: Job? = null
+    private var lastMediaSessionState = PlaybackStateCompat.STATE_NONE
+    private var lastMediaSessionPositionMs = -1L
+    private var lastMediaSessionUpdateElapsedMs = 0L
     @Volatile
     private var systemMediaCompatibilityEnabled =
         ReadConfig.systemMediaControlCompatibilityChange
@@ -189,6 +198,10 @@ abstract class BaseReadAloudService : BaseService(),
     protected val hasSpeechPlaybackQueue: Boolean
         get() = useSpeechPlaybackQueue && !playbackQueue.isEmpty
 
+    /** 当前朗读倍速, 用于把字符进度估算成媒体播放器时间轴 */
+    protected open val currentSpeechRate: Float
+        get() = 1f
+
     private val broadcastReceiver = object : BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
             if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) {
@@ -426,6 +439,7 @@ abstract class BaseReadAloudService : BaseService(),
         needResumeOnAudioFocusGain = false
         needResumeOnCallStateIdle = false
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
+        upMediaProgress()
         upReadAloudNotification()
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
@@ -447,6 +461,7 @@ abstract class BaseReadAloudService : BaseService(),
         if (abandonFocus) {
             abandonFocus()
         }
+        upMediaProgressJob?.cancel()
         upReadAloudNotification()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED)
         sessionStore.setStatus(ReadAloudSessionStatus.Paused)
@@ -471,6 +486,7 @@ abstract class BaseReadAloudService : BaseService(),
         needResumeOnCallStateIdle = false
         upReadAloudNotification()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
+        upMediaProgress()
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         if (!ReadBook.isAutoSaveSessionRunning) {
@@ -504,7 +520,12 @@ abstract class BaseReadAloudService : BaseService(),
 
     protected fun updateReadAloudProgressSnapshot(progress: Int) {
         currentChapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
-        currentProgress = progress.coerceAtLeast(0)
+        val newProgress = progress.coerceAtLeast(0)
+        if (newProgress < currentProgress) {
+            // 进度回退(上一段/上一章等), 重置媒体进度锚点, 允许进度条跟随回退
+            lastMediaSessionPositionMs = -1L
+        }
+        currentProgress = newProgress
     }
 
     protected fun moveToReadAloudPage(chapterPosition: Int): Boolean {
@@ -744,6 +765,21 @@ abstract class BaseReadAloudService : BaseService(),
      * 更新媒体状态
      */
     private fun upMediaSessionPlaybackState(state: Int) {
+        val now = SystemClock.elapsedRealtime()
+        val position = nextMediaSessionPositionMs(
+            state = state,
+            lastState = lastMediaSessionState,
+            estimate = mediaProgressPositionMs(),
+            lastPosition = lastMediaSessionPositionMs,
+            nowElapsedRealtime = now,
+            lastUpdateElapsedRealtime = lastMediaSessionUpdateElapsedMs,
+        )
+        if (state == lastMediaSessionState && position == lastMediaSessionPositionMs) {
+            return
+        }
+        lastMediaSessionState = state
+        lastMediaSessionPositionMs = position
+        lastMediaSessionUpdateElapsedMs = now
         val playbackState = PlaybackStateCompat.Builder()
             .setActions(
                 if (androidMediaControlEnabled) {
@@ -753,10 +789,10 @@ abstract class BaseReadAloudService : BaseService(),
                     MediaHelp.MEDIA_SESSION_ACTIONS
                 }
             )
-            // This is a normalized chapter-percentage scale, not a real time axis.
+            // TTS 没有真实时间轴, 位置按字符进度与语速估算为毫秒时间
             .setState(
                 state,
-                mediaProgressPositionMs(),
+                position,
                 if (state == PlaybackStateCompat.STATE_PLAYING) 1f else 0f,
             )
         if (androidMediaControlEnabled) {
@@ -777,15 +813,47 @@ abstract class BaseReadAloudService : BaseService(),
         )
     }
 
+    /**
+     * 播放时每秒推送一次媒体进度, 系统媒体播放器进度条随朗读推进
+     */
+    private fun upMediaProgress() {
+        upMediaProgressJob?.cancel()
+        upMediaProgressJob = lifecycleScope.launch {
+            while (isActive) {
+                refreshMediaSessionPlaybackState()
+                delay(1000)
+            }
+        }
+    }
+
     private fun mediaProgressPositionMs(): Long {
-        val playback = sessionStore.state.value.playback
-        return normalizedMediaProgressMs(
-            chapterPosition = playback.chapterPosition,
-            chapterLength = playback.chapterLength,
-            durationMs = MEDIA_PROGRESS_DURATION_MS,
+        val chapterLength = currentChapterLength()
+        val charsPerSecond = currentCharsPerSecond()
+        if (chapterLength <= 0 || charsPerSecond <= 0f) return 0L
+        return estimatedReadAloudTimeMs(
+            currentProgress.coerceIn(0, chapterLength),
+            charsPerSecond,
         )
     }
 
+    /**
+     * 当前章节按语速估算的朗读总时长, TTS 没有真实时间轴
+     */
+    private fun mediaProgressDurationMs(): Long {
+        val chapterLength = currentChapterLength()
+        val charsPerSecond = currentCharsPerSecond()
+        if (chapterLength <= 0 || charsPerSecond <= 0f) return 0L
+        return estimatedReadAloudTimeMs(chapterLength, charsPerSecond)
+    }
+
+    private fun currentChapterLength(): Int =
+        textChapter?.paragraphs?.lastOrNull()?.let { paragraph ->
+            paragraph.chapterPosition + paragraph.text.length
+        } ?: 0
+
+    private fun currentCharsPerSecond(): Float =
+        (ESTIMATED_CHARS_PER_SECOND * currentSpeechRate).coerceAtLeast(0.1f)
+
     /**
      * 更新媒体元数据, 用于车机蓝牙显示
      * @param showContent 是否显示当前朗读内容作为歌词
@@ -802,7 +870,7 @@ abstract class BaseReadAloudService : BaseService(),
             .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, textChapter?.title ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, ReadBook.book?.author ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentContent ?: "")
-            .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, MEDIA_PROGRESS_DURATION_MS)
+            .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, mediaProgressDurationMs())
             .build()
         mediaSessionCompat.setMetadata(metadata)
     }
@@ -889,6 +957,8 @@ abstract class BaseReadAloudService : BaseService(),
         if (enabled) {
             upMediaMetadata()
         }
+        // 强制重新推送, 让新的媒体按键集合生效
+        lastMediaSessionState = PlaybackStateCompat.STATE_NONE
         refreshMediaSessionPlaybackState()
         if (changed || enabled) {
             upReadAloudNotification()
@@ -1276,14 +1346,49 @@ abstract class BaseReadAloudService : BaseService(),
 
 }
 
-internal fun normalizedMediaProgressMs(
-    chapterPosition: Int,
-    chapterLength: Int,
-    durationMs: Long,
+/**
+ * 把已朗读字符数按每秒朗读字数估算为媒体播放器时间轴毫秒值
+ */
+internal fun estimatedReadAloudTimeMs(
+    chars: Int,
+    charsPerSecond: Float,
+): Long {
+    if (chars <= 0 || charsPerSecond <= 0f) return 0L
+    return (chars * 1000.0 / charsPerSecond).toLong()
+}
+
+/**
+ * 计算推送给系统媒体播放器的进度位置。
+ * 播放中保持单调不后退: 字符估算值领先时跟随估算值, 落后时按墙钟推进,
+ * 避免每秒重复推送同一估算值导致进度条来回跳变;
+ * 暂停时冻结在系统插值的显示位置, 恢复时从冻结位置继续, 不跳变。
+ */
+internal fun nextMediaSessionPositionMs(
+    state: Int,
+    lastState: Int,
+    estimate: Long,
+    lastPosition: Long,
+    nowElapsedRealtime: Long,
+    lastUpdateElapsedRealtime: Long,
 ): Long {
-    if (chapterLength <= 0 || durationMs <= 0) return 0L
-    val position = chapterPosition.coerceIn(0, chapterLength)
-    return position.toLong() * durationMs / chapterLength
+    val playing = PlaybackStateCompat.STATE_PLAYING
+    val paused = PlaybackStateCompat.STATE_PAUSED
+    return when {
+        state == paused && lastState == playing ->
+            if (lastPosition < 0) estimate
+            else lastPosition + (nowElapsedRealtime - lastUpdateElapsedRealtime)
+
+        state == paused -> lastPosition.coerceAtLeast(0)
+
+        state == playing && lastState == paused ->
+            if (lastPosition < 0) estimate else lastPosition
+
+        state == playing ->
+            if (lastPosition < 0) estimate
+            else maxOf(estimate, lastPosition + (nowElapsedRealtime - lastUpdateElapsedRealtime))
+
+        else -> estimate
+    }
 }
 
 /** Sentinel for "finish current chapter" timer not being armed. */

+ 4 - 0
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -102,6 +102,9 @@ class HttpReadAloudService : BaseReadAloudService(),
     Player.Listener {
     override val useSpeechPlaybackQueue: Boolean = true
 
+    protected override val currentSpeechRate: Float
+        get() = (speechRatePlay + 5) / 10f
+
     private val readAloudSettingsGateway = GlobalContext.get().get<ReadAloudSettingsGateway>()
     private val readSettingsGateway = GlobalContext.get().get<ReadSettingsGateway>()
     private val otherSettingsGateway = GlobalContext.get().get<OtherSettingsGateway>()
@@ -1093,6 +1096,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         preDownloadJob?.cancel()
         exoPlayer.stop()
         speechRate = speechRatePlay + 5
+        upMediaMetadata()
         if (readAloudSettings.streamReadAloudAudio) {
             downloadAndPlayAudiosStream()
         } else {

+ 4 - 0
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -40,6 +40,9 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
 
     override val useSpeechPlaybackQueue: Boolean = true
 
+    protected override val currentSpeechRate: Float
+        get() = if (ReadConfig.ttsFollowSys) 1f else (speechRateSetting + 5) / 10f
+
     private val readAloudSettingsGateway: ReadAloudSettingsGateway by inject()
     @Volatile
     private var speechRateSetting: Int = 5
@@ -304,6 +307,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         } else {
             val speechRate = (speechRateSetting + 5) / 10f
             textToSpeech?.setSpeechRate(speechRate)
+            upMediaMetadata()
             if (reset && !pause) {
                 play()
             }

+ 50 - 5
app/src/test/java/io/legado/app/service/TTSReadAloudProgressTest.kt

@@ -88,11 +88,56 @@ class TTSReadAloudProgressTest {
     }
 
     @Test
-    fun mediaProgressUsesTheSameChapterPercentageAsThePlayer() {
-        assertEquals(25_000L, normalizedMediaProgressMs(670, 2_680, 100_000L))
-        assertEquals(0L, normalizedMediaProgressMs(-10, 2_680, 100_000L))
-        assertEquals(100_000L, normalizedMediaProgressMs(3_000, 2_680, 100_000L))
-        assertEquals(0L, normalizedMediaProgressMs(10, 0, 100_000L))
+    fun mediaProgressEstimatesTimeFromCharactersAndSpeechRate() {
+        assertEquals(1_000L, estimatedReadAloudTimeMs(4, 4f))
+        assertEquals(2_000L, estimatedReadAloudTimeMs(8, 4f))
+        assertEquals(2_000L, estimatedReadAloudTimeMs(4, 2f))
+        assertEquals(0L, estimatedReadAloudTimeMs(0, 4f))
+        assertEquals(0L, estimatedReadAloudTimeMs(10, 0f))
+        assertEquals(0L, estimatedReadAloudTimeMs(-5, 4f))
+    }
+
+    @Test
+    fun mediaProgressStaysMonotonicWhilePlayingAndFreezesOnPause() {
+        // PlaybackStateCompat: STATE_PLAYING = 3, STATE_PAUSED = 2, STATE_STOPPED = 1
+        val playing = 3
+        val paused = 2
+        val stopped = 1
+        // 估算值落后时按墙钟推进, 不回退
+        assertEquals(
+            11_000L,
+            nextMediaSessionPositionMs(playing, playing, 9_000, 10_000, 61_000, 60_000)
+        )
+        // 估算值领先时跟随估算值
+        assertEquals(
+            12_000L,
+            nextMediaSessionPositionMs(playing, playing, 12_000, 10_000, 61_000, 60_000)
+        )
+        // 播放转暂停: 冻结在系统插值的显示位置
+        assertEquals(
+            11_500L,
+            nextMediaSessionPositionMs(paused, playing, 9_000, 10_000, 61_500, 60_000)
+        )
+        // 已暂停保持不变
+        assertEquals(
+            11_500L,
+            nextMediaSessionPositionMs(paused, paused, 9_000, 11_500, 70_000, 61_500)
+        )
+        // 暂停转播放: 从冻结位置继续, 不包含暂停时长
+        assertEquals(
+            11_500L,
+            nextMediaSessionPositionMs(playing, paused, 9_000, 11_500, 70_000, 61_500)
+        )
+        // 停止等其他状态用估算值
+        assertEquals(
+            9_000L,
+            nextMediaSessionPositionMs(stopped, playing, 9_000, 10_000, 61_000, 60_000)
+        )
+        // 进度回退后锚点失效, 直接用估算值
+        assertEquals(
+            2_000L,
+            nextMediaSessionPositionMs(playing, playing, 2_000, -1, 61_000, 60_000)
+        )
     }
 
 }