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

修复朗读错误重置,朗读进度强调与实际不同步的问题 (#1787)

* 修复朗读错误重置,朗读进度强调与实际不同步的问题

* 修复残留问题

---------

Co-authored-by: a328929 <a328929@gmail.com>
a328929 3 месяцев назад
Родитель
Сommit
11549b40a6

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

@@ -13,6 +13,7 @@ object IntentAction {
     const val prevParagraph = "prevParagraph"
     const val prevParagraph = "prevParagraph"
     const val nextParagraph = "nextParagraph"
     const val nextParagraph = "nextParagraph"
     const val upTtsSpeechRate = "upTtsSpeechRate"
     const val upTtsSpeechRate = "upTtsSpeechRate"
+    const val syncReadAloudLayout = "syncReadAloudLayout"
     const val upTtsProgress = "upTtsProgress"
     const val upTtsProgress = "upTtsProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustSpeed = "adjustSpeed"
     const val adjustSpeed = "adjustSpeed"
@@ -22,4 +23,4 @@ object IntentAction {
     const val init = "init"
     const val init = "init"
     const val remove = "remove"
     const val remove = "remove"
     const val stopPlay = "stopPlay"
     const val stopPlay = "stopPlay"
-}
+}

+ 82 - 17
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -62,7 +62,7 @@ class CacheBookModel(
     private val onDownloadSet = linkedSetOf<Int>()
     private val onDownloadSet = linkedSetOf<Int>()
     private val canceledDownloadSet = hashSetOf<Int>()
     private val canceledDownloadSet = hashSetOf<Int>()
     private val pausedChapterSet = linkedSetOf<Int>()
     private val pausedChapterSet = linkedSetOf<Int>()
-    private val pendingReadRequestMap = hashMapOf<Int, Boolean>()
+    private val pendingReadRequestMap = hashMapOf<Int, PendingReadRequest>()
     private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
     private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
     private val tasks = CompositeCoroutine()
     private val tasks = CompositeCoroutine()
     private val repository = CacheDownloadRepository()
     private val repository = CacheDownloadRepository()
@@ -671,7 +671,8 @@ class CacheBookModel(
         scope: CoroutineScope,
         scope: CoroutineScope,
         chapter: BookChapter,
         chapter: BookChapter,
         semaphore: Semaphore?,
         semaphore: Semaphore?,
-        resetPageOffset: Boolean = false
+        resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ): Boolean {
     ): Boolean {
         if (!markChapterDownloadStarted(chapter.index)) {
         if (!markChapterDownloadStarted(chapter.index)) {
             // Chapter is already in onDownloadSet. Check if the task is actually alive.
             // Chapter is already in onDownloadSet. Check if the task is actually alive.
@@ -686,11 +687,19 @@ class CacheBookModel(
                 }
                 }
                 notifyDownloadSetChanged()
                 notifyDownloadSetChanged()
                 if (!markChapterDownloadStarted(chapter.index)) {
                 if (!markChapterDownloadStarted(chapter.index)) {
-                    markPendingReadRequest(chapter.index, resetPageOffset)
+                    markPendingReadRequest(
+                        chapter.index,
+                        resetPageOffset,
+                        preserveReadAloudPosition,
+                    )
                     return false
                     return false
                 }
                 }
             } else {
             } else {
-                markPendingReadRequest(chapter.index, resetPageOffset)
+                markPendingReadRequest(
+                    chapter.index,
+                    resetPageOffset,
+                    preserveReadAloudPosition,
+                )
                 return true
                 return true
             }
             }
         }
         }
@@ -712,17 +721,33 @@ class CacheBookModel(
             onSuccess(chapter)
             onSuccess(chapter)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
-            downloadFinish(chapter, content, resetPageOffset)
+            downloadFinish(
+                chapter,
+                content,
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadContent(chapter, content)
             emitPendingReadContent(chapter, content)
         }.onError {
         }.onError {
             onError(chapter, it)
             onError(chapter, it)
             ReadBook.downloadFailChapters[chapter.index] =
             ReadBook.downloadFailChapters[chapter.index] =
                 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
                 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
-            downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
+            downloadFinish(
+                chapter,
+                "获取正文失败\n${it.localizedMessage}",
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadError(chapter, it)
             emitPendingReadError(chapter, it)
         }.onCancel {
         }.onCancel {
             onCancel(chapter.index, requeue = false)
             onCancel(chapter.index, requeue = false)
-            downloadFinish(chapter, "download canceled", resetPageOffset, true)
+            downloadFinish(
+                chapter,
+                "download canceled",
+                resetPageOffset,
+                canceled = true,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
         }.onFinally {
         }.onFinally {
             chapterTasks.remove(chapter.index)
             chapterTasks.remove(chapter.index)
             host.onTaskQueuesChanged(book.bookUrl)
             host.onTaskQueuesChanged(book.bookUrl)
@@ -744,35 +769,59 @@ class CacheBookModel(
     }
     }
 
 
     @Synchronized
     @Synchronized
-    private fun markPendingReadRequest(index: Int, resetPageOffset: Boolean) {
-        pendingReadRequestMap[index] = pendingReadRequestMap[index] == true || resetPageOffset
+    private fun markPendingReadRequest(
+        index: Int,
+        resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean,
+    ) {
+        pendingReadRequestMap[index] = mergePendingReadRequest(
+            previous = pendingReadRequestMap[index],
+            resetPageOffset = resetPageOffset,
+            preserveReadAloudPosition = preserveReadAloudPosition,
+        )
     }
     }
 
 
     @Synchronized
     @Synchronized
-    private fun consumePendingReadRequest(index: Int): Boolean? {
+    private fun consumePendingReadRequest(index: Int): PendingReadRequest? {
         return pendingReadRequestMap.remove(index)
         return pendingReadRequestMap.remove(index)
     }
     }
 
 
     private fun emitPendingReadContent(chapter: BookChapter, content: String) {
     private fun emitPendingReadContent(chapter: BookChapter, content: String) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, content, resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            content,
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun emitPendingReadError(chapter: BookChapter, error: Throwable) {
     private fun emitPendingReadError(chapter: BookChapter, error: Throwable) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, "获取正文失败\n${error.localizedMessage}", resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            "获取正文失败\n${error.localizedMessage}",
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun emitPendingReadCanceled(chapter: BookChapter) {
     private fun emitPendingReadCanceled(chapter: BookChapter) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, "download canceled", resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            "download canceled",
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun downloadFinish(
     private fun downloadFinish(
         chapter: BookChapter,
         chapter: BookChapter,
         content: String,
         content: String,
         resetPageOffset: Boolean = false,
         resetPageOffset: Boolean = false,
-        canceled: Boolean = false
+        canceled: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ) {
     ) {
         if (ReadBook.book?.bookUrl == book.bookUrl) {
         if (ReadBook.book?.bookUrl == book.bookUrl) {
             ReadBook.contentLoadFinish(
             ReadBook.contentLoadFinish(
@@ -781,7 +830,23 @@ class CacheBookModel(
                 content = content,
                 content = content,
                 resetPageOffset = resetPageOffset,
                 resetPageOffset = resetPageOffset,
                 canceled = canceled,
                 canceled = canceled,
+                preserveReadAloudPosition = preserveReadAloudPosition,
             )
             )
         }
         }
     }
     }
 }
 }
+
+internal data class PendingReadRequest(
+    val resetPageOffset: Boolean,
+    val preserveReadAloudPosition: Boolean,
+)
+
+internal fun mergePendingReadRequest(
+    previous: PendingReadRequest?,
+    resetPageOffset: Boolean,
+    preserveReadAloudPosition: Boolean,
+): PendingReadRequest = PendingReadRequest(
+    resetPageOffset = previous?.resetPageOffset == true || resetPageOffset,
+    preserveReadAloudPosition =
+        (previous?.preserveReadAloudPosition ?: true) && preserveReadAloudPosition,
+)

+ 8 - 0
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -126,6 +126,14 @@ object ReadAloud {
         }
         }
     }
     }
 
 
+    fun syncLayout(context: Context = appCtx) {
+        if (BaseReadAloudService.isRun) {
+            val intent = Intent(context, aloudClass)
+            intent.action = IntentAction.syncReadAloudLayout
+            context.startForegroundServiceCompat(intent)
+        }
+    }
+
     fun setTimer(context: Context, minute: Int) {
     fun setTimer(context: Context, minute: Int) {
         if (BaseReadAloudService.isRun) {
         if (BaseReadAloudService.isRun) {
             val intent = Intent(context, aloudClass)
             val intent = Intent(context, aloudClass)

+ 12 - 0
app/src/main/java/io/legado/app/model/ReadAloudLayoutPolicy.kt

@@ -0,0 +1,12 @@
+package io.legado.app.model
+
+internal fun shouldRestartReadAloudAfterContentLoad(
+    preserveReadAloudPosition: Boolean,
+    serviceChapterIndex: Int,
+    loadedChapterIndex: Int,
+): Boolean = !preserveReadAloudPosition || serviceChapterIndex != loadedChapterIndex
+
+internal fun activeReadAloudProgress(
+    isPlaying: Boolean,
+    currentProgress: Int,
+): Int? = currentProgress.takeIf { isPlaying && it > 0 }

+ 65 - 11
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -627,15 +627,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     /**
     /**
      * 当前页面变化
      * 当前页面变化
      */
      */
-    private fun curPageChanged(pageChanged: Boolean = false) {
+    private fun curPageChanged(
+        pageChanged: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
+    ) {
         callBack?.pageChanged()
         callBack?.pageChanged()
         curTextChapter?.let {
         curTextChapter?.let {
             if (BaseReadAloudService.isRun && it.isCompleted) {
             if (BaseReadAloudService.isRun && it.isCompleted) {
-                val scrollPageAnim = pageAnim() == 3
-                if (scrollPageAnim && pageChanged) {
-                    ReadAloud.pause(appCtx)
+                if (shouldRestartReadAloudAfterContentLoad(
+                        preserveReadAloudPosition = preserveReadAloudPosition,
+                        serviceChapterIndex = BaseReadAloudService.currentChapterIndex,
+                        loadedChapterIndex = it.chapter.index,
+                    )
+                ) {
+                    if (isScroll && pageChanged) {
+                        ReadAloud.pause(appCtx)
+                    } else {
+                        readAloud(!BaseReadAloudService.pause)
+                    }
                 } else {
                 } else {
-                    readAloud(!BaseReadAloudService.pause)
+                    ReadAloud.syncLayout()
                 }
                 }
             }
             }
         }
         }
@@ -654,6 +665,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
         }
     }
     }
 
 
+    fun syncReadAloudPage(chapterIndex: Int, chapterPos: Int) {
+        if (durChapterIndex != chapterIndex || durChapterPos == chapterPos) return
+        durChapterPos = chapterPos
+        callBack?.upContent(resetPageOffset = false)
+        saveRead(pageChanged = true)
+    }
+
     /**
     /**
      * 当前页数
      * 当前页数
      */
      */
@@ -694,22 +712,39 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
      */
     fun loadContent(
     fun loadContent(
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
-        loadContent(durChapterIndex, resetPageOffset = resetPageOffset) {
+        loadContent(
+            durChapterIndex,
+            resetPageOffset = resetPageOffset,
+            preserveReadAloudPosition = preserveReadAloudPosition,
+        ) {
             success?.invoke()
             success?.invoke()
         }
         }
         loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
     }
     }
 
 
+    fun relayoutContent() {
+        loadContent(
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+    }
+
     fun loadOrUpContent(success: (() -> Unit)? = null) {
     fun loadOrUpContent(success: (() -> Unit)? = null) {
         val curChapter = curTextChapter
         val curChapter = curTextChapter
         if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
         if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
+            val preserveReadAloudPosition = BaseReadAloudService.isRun &&
+                    BaseReadAloudService.currentChapterIndex == durChapterIndex
             curTextChapter = null
             curTextChapter = null
             nextTextChapter = null
             nextTextChapter = null
             prevTextChapter = null
             prevTextChapter = null
-            loadContent(durChapterIndex) {
+            loadContent(
+                durChapterIndex,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            ) {
                 success?.invoke()
                 success?.invoke()
             }
             }
         } else {
         } else {
@@ -738,6 +773,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         index: Int,
         index: Int,
         upContent: Boolean = true,
         upContent: Boolean = true,
         resetPageOffset: Boolean = false,
         resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
         Coroutine.async {
         Coroutine.async {
@@ -767,12 +803,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         it,
                         it,
                         upContent,
                         upContent,
                         resetPageOffset,
                         resetPageOffset,
+                        preserveReadAloudPosition,
                         success = success
                         success = success
                     )
                     )
                 } ?: download(
                 } ?: download(
                     downloadScope,
                     downloadScope,
                     chapter,
                     chapter,
-                    resetPageOffset
+                    resetPageOffset,
+                    preserveReadAloudPosition = preserveReadAloudPosition,
                 )
                 )
             }
             }
         }.onError {
         }.onError {
@@ -831,7 +869,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         } else {
         } else {
             delay(1000)
             delay(1000)
             if (addLoading(index)) {
             if (addLoading(index)) {
-                download(downloadScope, chapter, false, preDownloadSemaphore)
+                download(
+                    scope = downloadScope,
+                    chapter = chapter,
+                    resetPageOffset = false,
+                    semaphore = preDownloadSemaphore,
+                )
             }
             }
         }
         }
     }
     }
@@ -843,6 +886,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         scope: CoroutineScope,
         scope: CoroutineScope,
         chapter: BookChapter,
         chapter: BookChapter,
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         semaphore: Semaphore? = null,
         semaphore: Semaphore? = null,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
@@ -850,7 +894,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val bookSource = bookSource
         val bookSource = bookSource
         if (bookSource != null) {
         if (bookSource != null) {
             val started =
             val started =
-                CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
+                CacheBook.getOrCreate(bookSource, book).download(
+                    scope = scope,
+                    chapter = chapter,
+                    semaphore = semaphore,
+                    resetPageOffset = resetPageOffset,
+                    preserveReadAloudPosition = preserveReadAloudPosition,
+                )
             if (!started) {
             if (!started) {
                 removeLoading(chapter.index)
                 removeLoading(chapter.index)
             }
             }
@@ -861,6 +911,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 chapter,
                 chapter,
                 "加载正文失败\n$msg",
                 "加载正文失败\n$msg",
                 resetPageOffset = resetPageOffset,
                 resetPageOffset = resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
                 success = success
                 success = success
             )
             )
         }
         }
@@ -927,6 +978,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         content: String,
         content: String,
         upContent: Boolean = true,
         upContent: Boolean = true,
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         canceled: Boolean = false,
         canceled: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
@@ -971,7 +1023,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         callBack?.onLayoutPageCompleted(index, page)
                         callBack?.onLayoutPageCompleted(index, page)
                     }
                     }
                     if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
                     if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
-                    curPageChanged()
+                    curPageChanged(
+                        preserveReadAloudPosition = preserveReadAloudPosition,
+                    )
                     callBack?.contentLoadFinish()
                     callBack?.contentLoadFinish()
                 }
                 }
 
 

+ 71 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -88,6 +88,16 @@ abstract class BaseReadAloudService : BaseService(),
                 field = PlaybackTimer.normalize(value)
                 field = PlaybackTimer.normalize(value)
             }
             }
 
 
+        @JvmStatic
+        @Volatile
+        var currentChapterIndex: Int = -1
+            private set
+
+        @JvmStatic
+        @Volatile
+        var currentProgress: Int = 0
+            private set
+
         fun isPlay(): Boolean {
         fun isPlay(): Boolean {
             return isRun && !pause
             return isRun && !pause
         }
         }
@@ -199,6 +209,8 @@ abstract class BaseReadAloudService : BaseService(),
         }
         }
         isRun = false
         isRun = false
         pause = true
         pause = true
+        currentChapterIndex = -1
+        currentProgress = 0
         abandonFocus()
         abandonFocus()
         unregisterReceiver(broadcastReceiver)
         unregisterReceiver(broadcastReceiver)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
@@ -227,6 +239,7 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.pause -> pauseReadAloud()
             IntentAction.pause -> pauseReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
+            IntentAction.syncReadAloudLayout -> syncTextChapterLayout()
             IntentAction.prevParagraph -> prevP()
             IntentAction.prevParagraph -> prevP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.prev -> prevChapter()
             IntentAction.prev -> prevChapter()
@@ -274,6 +287,7 @@ abstract class BaseReadAloudService : BaseService(),
                             textChapter.paragraphs[nowSpeak].chapterPosition
                             textChapter.paragraphs[nowSpeak].chapterPosition
                 }
                 }
             }
             }
+            updateReadAloudProgressSnapshot(readAloudNumber + 1)
             paragraphStartPos = pos
             paragraphStartPos = pos
             launch(Main) {
             launch(Main) {
                 upMediaMetadata()
                 upMediaMetadata()
@@ -347,9 +361,50 @@ abstract class BaseReadAloudService : BaseService(),
 
 
     fun upTtsProgress(progress: Int) {
     fun upTtsProgress(progress: Int) {
         ReadBook.upReadTime()
         ReadBook.upReadTime()
+        updateReadAloudProgressSnapshot(progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
     }
     }
 
 
+    protected fun updateReadAloudProgressSnapshot(progress: Int) {
+        currentChapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
+        currentProgress = progress.coerceAtLeast(0)
+    }
+
+    protected fun moveToReadAloudPage(chapterPosition: Int): Boolean {
+        val chapter = textChapter ?: return false
+        val targetPageIndex = findReadAloudPageIndex(
+            currentPageIndex = pageIndex,
+            chapterPosition = chapterPosition,
+            pageCount = chapter.pageSize,
+            pageStart = chapter::getReadLength,
+        )
+        if (targetPageIndex == pageIndex) return false
+        repeat(targetPageIndex - pageIndex) {
+            pageIndex++
+            ReadBook.moveToNextPage()
+        }
+        return true
+    }
+
+    private fun syncTextChapterLayout() {
+        val latestChapter = ReadBook.curTextChapter ?: return
+        val serviceChapter = textChapter ?: return
+        if (!latestChapter.isCompleted || latestChapter.chapter.index != serviceChapter.chapter.index) {
+            return
+        }
+        val latestPageIndex = latestChapter.getPageIndexByCharIndex(
+            (currentProgress - 1).coerceAtLeast(0)
+        )
+        if (latestPageIndex < 0) return
+        textChapter = latestChapter
+        pageIndex = latestPageIndex
+        ReadBook.syncReadAloudPage(
+            chapterIndex = latestChapter.chapter.index,
+            chapterPos = latestChapter.getReadLength(latestPageIndex),
+        )
+        upTtsProgress(currentProgress)
+    }
+
     private fun prevP() {
     private fun prevP() {
         ReadBook.upReadTime()
         ReadBook.upReadTime()
         if (nowSpeak > 0) {
         if (nowSpeak > 0) {
@@ -816,3 +871,19 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
 }
 }
+
+internal inline fun findReadAloudPageIndex(
+    currentPageIndex: Int,
+    chapterPosition: Int,
+    pageCount: Int,
+    pageStart: (Int) -> Int,
+): Int {
+    var targetPageIndex = currentPageIndex
+    while (
+        targetPageIndex + 1 < pageCount &&
+        chapterPosition > pageStart(targetPageIndex + 1)
+    ) {
+        targetPageIndex++
+    }
+    return targetPageIndex
+}

+ 7 - 8
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -592,10 +592,10 @@ class HttpReadAloudService : BaseReadAloudService(),
 
 
     private fun upPlayPos() {
     private fun upPlayPos() {
         playIndexJob?.cancel()
         playIndexJob?.cancel()
-        val textChapter = textChapter ?: return
+        if (textChapter == null) return
         playIndexJob = lifecycleScope.launch {
         playIndexJob = lifecycleScope.launch {
-            upTtsProgress(readAloudNumber + 1)
             if (exoPlayer.duration <= 0) {
             if (exoPlayer.duration <= 0) {
+                upTtsProgress(readAloudNumber + 1)
                 return@launch
                 return@launch
             }
             }
             val speakTextLength = contentList[nowSpeak].length
             val speakTextLength = contentList[nowSpeak].length
@@ -604,13 +604,12 @@ class HttpReadAloudService : BaseReadAloudService(),
             }
             }
             val sleep = exoPlayer.duration / speakTextLength
             val sleep = exoPlayer.duration / speakTextLength
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
+            upTtsProgress(readAloudNumber + start.toInt() + 1)
             for (i in start..contentList[nowSpeak].length) {
             for (i in start..contentList[nowSpeak].length) {
-                if (pageIndex + 1 < textChapter.pageSize
-                    && readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)
-                ) {
-                    pageIndex++
-                    ReadBook.moveToNextPage()
-                    upTtsProgress(readAloudNumber + i.toInt())
+                val chapterPosition = readAloudNumber + i.toInt()
+                updateReadAloudProgressSnapshot(chapterPosition + 1)
+                if (moveToReadAloudPage(chapterPosition)) {
+                    upTtsProgress(chapterPosition + 1)
                 }
                 }
                 delay(sleep)
                 delay(sleep)
             }
             }

+ 22 - 9
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -33,6 +33,7 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
     private val ttsUtteranceListener = TTSUtteranceListener()
     private val ttsUtteranceListener = TTSUtteranceListener()
     private var speakJob: Coroutine<*>? = null
     private var speakJob: Coroutine<*>? = null
     private var utteranceStartPos = 0
     private var utteranceStartPos = 0
+    private var utteranceStartReadAloudNumber = 0
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
     private val TAG = "TTSReadAloudService"
     private val TAG = "TTSReadAloudService"
 
 
@@ -243,6 +244,7 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
         override fun onStart(s: String) {
         override fun onStart(s: String) {
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
             utteranceStartPos = paragraphStartPos
+            utteranceStartReadAloudNumber = readAloudNumber
             textChapter?.let {
             textChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph()
                     nextParagraph()
@@ -270,17 +272,13 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
         override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
         override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
             super.onRangeStart(utteranceId, start, end, frame)
             super.onRangeStart(utteranceId, start, end, frame)
             paragraphStartPos = utteranceStartPos + start
             paragraphStartPos = utteranceStartPos + start
+            readAloudNumber = currentRangePosition(utteranceStartReadAloudNumber, start)
+            updateReadAloudProgressSnapshot(readAloudNumber + 1)
             val msg =
             val msg =
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
             LogUtils.d(TAG, msg)
             LogUtils.d(TAG, msg)
-            textChapter?.let {
-                if (pageIndex + 1 < it.pageSize
-                    && readAloudNumber + start > it.getReadLength(pageIndex + 1)
-                ) {
-                    pageIndex++
-                    ReadBook.moveToNextPage()
-                    upTtsProgress(readAloudNumber + start)
-                }
+            if (moveToReadAloudPage(readAloudNumber)) {
+                upTtsProgress(readAloudNumber + 1)
             }
             }
         }
         }
 
 
@@ -299,7 +297,11 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
         private fun nextParagraph() {
         private fun nextParagraph() {
             //跳过全标点段落
             //跳过全标点段落
             do {
             do {
-                readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
+                readAloudNumber = nextParagraphPosition(
+                    currentPosition = readAloudNumber,
+                    paragraphLength = contentList[nowSpeak].length,
+                    paragraphStartPosition = paragraphStartPos,
+                )
                 paragraphStartPos = 0
                 paragraphStartPos = 0
                 nowSpeak++
                 nowSpeak++
                 if (nowSpeak >= contentList.size) {
                 if (nowSpeak >= contentList.size) {
@@ -326,3 +328,14 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
     }
     }
 
 
 }
 }
+
+internal fun nextParagraphPosition(
+    currentPosition: Int,
+    paragraphLength: Int,
+    paragraphStartPosition: Int,
+): Int = currentPosition + paragraphLength + 1 - paragraphStartPosition
+
+internal fun currentRangePosition(
+    utteranceStartPosition: Int,
+    rangeStart: Int,
+): Int = utteranceStartPosition + rangeStart

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -791,7 +791,6 @@ sealed interface ReadBookEffect {
     data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect
     data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect
 
 
     // TTS
     // TTS
-    data class UpTtsAloudSpan(val chapterStart: Int) : ReadBookEffect
 
 
     // Dialogs (Activity-driven)
     // Dialogs (Activity-driven)
     data object ShowConfirmSkipToChapter : ReadBookEffect
     data object ShowConfirmSkipToChapter : ReadBookEffect
@@ -905,6 +904,7 @@ sealed interface ConfigUpdateAction {
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
+    data object RelayoutContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
     data object UpdateChapterStyle : ConfigUpdateAction
     data object UpdateChapterStyle : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction

+ 13 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -813,6 +813,7 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
                         ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
                         ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage()
                         ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage()
@@ -879,20 +880,6 @@ class ReadBookController(
                 }
                 }
             }
             }
 
 
-            is ReadBookEffect.UpTtsAloudSpan -> {
-                activity.lifecycleScope.launch(IO) {
-                    if (BaseReadAloudService.isPlay()) {
-                        ReadBook.curTextChapter?.let { textChapter ->
-                            val pageIndex = ReadBook.durPageIndex
-                            val aloudSpanStart =
-                                effect.chapterStart - textChapter.getReadLength(pageIndex)
-                            textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
-                            refs?.readView?.upContent()
-                        }
-                    }
-                }
-            }
-
             is ReadBookEffect.RefreshBookContent -> {
             is ReadBookEffect.RefreshBookContent -> {
                 ReadBook.curTextChapter = null
                 ReadBook.curTextChapter = null
                 refs?.readView?.upContent()
                 refs?.readView?.upContent()
@@ -911,6 +898,7 @@ class ReadBookController(
             }
             }
 
 
             is ReadBookEffect.ContentLoadFinish -> {
             is ReadBookEffect.ContentLoadFinish -> {
+                viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
                 onStartContentLoadFinish?.invoke()
                 onStartContentLoadFinish?.invoke()
             }
             }
 
 
@@ -1060,6 +1048,17 @@ class ReadBookController(
         }
         }
     }
     }
 
 
+    fun updateReadAloudProgress(chapterStart: Int) {
+        if (!BaseReadAloudService.isPlay()) return
+        val textChapter = ReadBook.curTextChapter ?: return
+        if (textChapter.chapter.index != BaseReadAloudService.currentChapterIndex) return
+        val pageIndex = textChapter.getPageIndexByCharIndex(chapterStart)
+        if (pageIndex < 0 || pageIndex != ReadBook.durPageIndex) return
+        val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex)
+        textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
+        refs?.readView?.upContent(resetPageOffset = false)
+    }
+
     // ── Key handling ──
     // ── Key handling ──
 
 
     private fun toggleReadAloud() {
     private fun toggleReadAloud() {

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -135,6 +135,14 @@ fun ReadBookRouteScreen(
         controller.onMenuVisibilityChanged(state.menuVisible)
         controller.onMenuVisibilityChanged(state.menuVisible)
     }
     }
 
 
+    LaunchedEffect(viewModel, controller, lifecycleOwner) {
+        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            viewModel.readAloudProgress.collect { chapterStart ->
+                chapterStart?.let(controller::updateReadAloudProgress)
+            }
+        }
+    }
+
     // ── ActivityResult Launchers ──────────────────────────────────────
     // ── ActivityResult Launchers ──────────────────────────────────────
 
 
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->

+ 19 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -75,6 +75,7 @@ import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
+import io.legado.app.model.activeReadAloudProgress
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
@@ -180,6 +181,14 @@ class ReadBookViewModel(
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
+    private val _readAloudProgress = MutableStateFlow(
+        activeReadAloudProgress(
+            isPlaying = BaseReadAloudService.isPlay(),
+            currentProgress = BaseReadAloudService.currentProgress,
+        )
+    )
+    val readAloudProgress = _readAloudProgress.asStateFlow()
+
     private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
     private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
         _effects.subscriptionCount.first { it > 0 }
         _effects.subscriptionCount.first { it > 0 }
         _effects.emit(effect)
         _effects.emit(effect)
@@ -868,7 +877,7 @@ class ReadBookViewModel(
                 }
                 }
             }
             }
 
 
-            is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart))
+            is ReadBookIntent.TtsProgress -> updateReadAloudProgress(intent.chapterStart)
             is ReadBookIntent.ReadAloudAction -> {
             is ReadBookIntent.ReadAloudAction -> {
                 openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
                 openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
             }
             }
@@ -1826,6 +1835,7 @@ class ReadBookViewModel(
                         9 -> ConfigUpdateAction.InvalidateTextPage
                         9 -> ConfigUpdateAction.InvalidateTextPage
                         10 -> ConfigUpdateAction.UpdateLayout
                         10 -> ConfigUpdateAction.UpdateLayout
                         11 -> ConfigUpdateAction.SubmitRenderTask
                         11 -> ConfigUpdateAction.SubmitRenderTask
+                        12 -> ConfigUpdateAction.RelayoutContent
                         else -> null
                         else -> null
                     }
                     }
                 }.toSet()
                 }.toSet()
@@ -1843,6 +1853,7 @@ class ReadBookViewModel(
                     )
                     )
                 }
                 }
                 if (state == Status.STOP || state == Status.PAUSE) {
                 if (state == Status.STOP || state == Status.PAUSE) {
+                    _readAloudProgress.value = null
                     _effects.tryEmit(ReadBookEffect.UpAloudState)
                     _effects.tryEmit(ReadBookEffect.UpAloudState)
                 }
                 }
                 if (state == Status.PAUSE) {
                 if (state == Status.PAUSE) {
@@ -1885,11 +1896,17 @@ class ReadBookViewModel(
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
             eventFlowSticky<Int>(EventBus.TTS_PROGRESS).collect { chapterStart ->
             eventFlowSticky<Int>(EventBus.TTS_PROGRESS).collect { chapterStart ->
-                _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(chapterStart))
+                updateReadAloudProgress(chapterStart)
             }
             }
         }
         }
     }
     }
 
 
+    private fun updateReadAloudProgress(chapterStart: Int) {
+        if (BaseReadAloudService.isPlay() && chapterStart > 0) {
+            _readAloudProgress.value = chapterStart
+        }
+    }
+
     private fun collectReadPreferences() {
     private fun collectReadPreferences() {
         viewModelScope.launch {
         viewModelScope.launch {
             var previous: ReadPreferences? = null
             var previous: ReadPreferences? = null

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -1082,7 +1082,7 @@ object ChapterProvider {
         viewWidth = width
         viewWidth = width
         viewHeight = height
         viewHeight = height
         upLayout()
         upLayout()
-        postEvent(EventBus.UP_CONFIG, arrayListOf(5))
+        postEvent(EventBus.UP_CONFIG, arrayListOf(12))
     }
     }
 
 
     /**
     /**

+ 69 - 0
app/src/test/java/io/legado/app/model/CacheBookPendingReadRequestTest.kt

@@ -0,0 +1,69 @@
+package io.legado.app.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CacheBookPendingReadRequestTest {
+
+    @Test
+    fun firstRelayoutRequestPreservesReadAloudPosition() {
+        val request = mergePendingReadRequest(
+            previous = null,
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+
+        assertEquals(
+            PendingReadRequest(
+                resetPageOffset = false,
+                preserveReadAloudPosition = true,
+            ),
+            request,
+        )
+    }
+
+    @Test
+    fun resetPageOffsetIsRetainedAcrossPendingRequests() {
+        val request = mergePendingReadRequest(
+            previous = PendingReadRequest(
+                resetPageOffset = true,
+                preserveReadAloudPosition = true,
+            ),
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+
+        assertTrue(request.resetPageOffset)
+        assertTrue(request.preserveReadAloudPosition)
+    }
+
+    @Test
+    fun anyNavigationRequestDisablesPositionPreservation() {
+        val request = mergePendingReadRequest(
+            previous = PendingReadRequest(
+                resetPageOffset = false,
+                preserveReadAloudPosition = true,
+            ),
+            resetPageOffset = false,
+            preserveReadAloudPosition = false,
+        )
+
+        assertFalse(request.preserveReadAloudPosition)
+    }
+
+    @Test
+    fun laterRelayoutCannotOverrideNavigationRequest() {
+        val request = mergePendingReadRequest(
+            previous = PendingReadRequest(
+                resetPageOffset = false,
+                preserveReadAloudPosition = false,
+            ),
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+
+        assertFalse(request.preserveReadAloudPosition)
+    }
+}

+ 64 - 0
app/src/test/java/io/legado/app/model/ReadAloudLayoutPolicyTest.kt

@@ -0,0 +1,64 @@
+package io.legado.app.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ReadAloudLayoutPolicyTest {
+
+    @Test
+    fun sameChapterRelayoutKeepsCurrentPlaybackPosition() {
+        assertFalse(
+            shouldRestartReadAloudAfterContentLoad(
+                preserveReadAloudPosition = true,
+                serviceChapterIndex = 4,
+                loadedChapterIndex = 4,
+            )
+        )
+    }
+
+    @Test
+    fun userPositionChangeRestartsPlayback() {
+        assertTrue(
+            shouldRestartReadAloudAfterContentLoad(
+                preserveReadAloudPosition = false,
+                serviceChapterIndex = 4,
+                loadedChapterIndex = 4,
+            )
+        )
+    }
+
+    @Test
+    fun nextChapterLoadStartsPendingChapter() {
+        assertTrue(
+            shouldRestartReadAloudAfterContentLoad(
+                preserveReadAloudPosition = true,
+                serviceChapterIndex = 4,
+                loadedChapterIndex = 5,
+            )
+        )
+    }
+
+    @Test
+    fun activePlaybackRestoresLatestServiceProgress() {
+        assertEquals(
+            128,
+            activeReadAloudProgress(
+                isPlaying = true,
+                currentProgress = 128,
+            )
+        )
+    }
+
+    @Test
+    fun inactivePlaybackDoesNotRestoreStaleProgress() {
+        assertNull(
+            activeReadAloudProgress(
+                isPlaying = false,
+                currentProgress = 128,
+            )
+        )
+    }
+}

+ 89 - 0
app/src/test/java/io/legado/app/service/TTSReadAloudProgressTest.kt

@@ -0,0 +1,89 @@
+package io.legado.app.service
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TTSReadAloudProgressTest {
+
+    @Test
+    fun nextParagraphAdvancesFromCurrentRangePosition() {
+        assertEquals(
+            201,
+            nextParagraphPosition(
+                currentPosition = 180,
+                paragraphLength = 100,
+                paragraphStartPosition = 80,
+            )
+        )
+    }
+
+    @Test
+    fun nextParagraphPreservesInitialSelectionOffset() {
+        assertEquals(
+            201,
+            nextParagraphPosition(
+                currentPosition = 120,
+                paragraphLength = 100,
+                paragraphStartPosition = 20,
+            )
+        )
+    }
+
+    @Test
+    fun rangeProgressUsesTheUtteranceStartSnapshot() {
+        assertEquals(
+            340,
+            currentRangePosition(
+                utteranceStartPosition = 200,
+                rangeStart = 140,
+            )
+        )
+    }
+
+    @Test
+    fun longParagraphRangeCanCrossSeveralPages() {
+        val pageStarts = listOf(0, 100, 200, 300, 400)
+
+        assertEquals(
+            3,
+            findReadAloudPageIndex(
+                currentPageIndex = 0,
+                chapterPosition = 350,
+                pageCount = pageStarts.size,
+                pageStart = pageStarts::get,
+            )
+        )
+    }
+
+    @Test
+    fun pageBoundaryChangesAreUsedAfterRelayout() {
+        var pageStarts = listOf(0, 100, 200, 300)
+        val pageStart = { index: Int -> pageStarts[index] }
+
+        assertEquals(
+            2,
+            findReadAloudPageIndex(0, 250, pageStarts.size, pageStart)
+        )
+
+        pageStarts = listOf(0, 180, 360)
+
+        assertEquals(
+            1,
+            findReadAloudPageIndex(0, 250, pageStarts.size, pageStart)
+        )
+    }
+
+    @Test
+    fun exactPageBoundaryWaitsForPlaybackToEnterThePage() {
+        val pageStarts = listOf(0, 100, 200)
+
+        assertEquals(
+            0,
+            findReadAloudPageIndex(0, 100, pageStarts.size, pageStarts::get)
+        )
+        assertEquals(
+            1,
+            findReadAloudPageIndex(0, 101, pageStarts.size, pageStarts::get)
+        )
+    }
+}