fix: 修复阅读界面功能问题

This commit is contained in:
HapeLee
2026-06-28 02:18:12 +08:00
parent 3052175432
commit eb0a0ff2ff
11 changed files with 166 additions and 53 deletions
@@ -21,6 +21,28 @@ class SearchContentRepository {
private var lastSearchResults: List<SearchResult>? = null
private var lastQueryKey: String? = null
private var lastSearchSession: SearchSession? = null
data class SearchSession(
val bookUrl: String,
val query: String,
val replaceEnabled: Boolean,
val regexReplace: Boolean,
val results: List<SearchResult>,
)
fun getLastSession(bookUrl: String): SearchSession? {
return lastSearchSession?.takeIf { it.bookUrl == bookUrl }
}
fun beginSearch(
bookUrl: String,
query: String,
replaceEnabled: Boolean,
regexReplace: Boolean,
) {
cacheResults(bookUrl, query, replaceEnabled, regexReplace, emptyList())
}
fun getCache(
bookUrl: String,
@@ -62,15 +84,34 @@ class SearchContentRepository {
if (chapterResults.isNotEmpty()) {
allResults.addAll(chapterResults)
cacheResults(book.bookUrl, query, replaceEnabled, regexReplace, allResults)
emit(ArrayList(allResults))
}
}
}
lastSearchResults = allResults
lastQueryKey = searchKey(book.bookUrl, query, replaceEnabled, regexReplace)
cacheResults(book.bookUrl, query, replaceEnabled, regexReplace, allResults)
emit(ArrayList(allResults))
}.flowOn(Dispatchers.Default)
private fun cacheResults(
bookUrl: String,
query: String,
replaceEnabled: Boolean,
regexReplace: Boolean,
results: List<SearchResult>,
) {
val cachedResults = ArrayList(results)
lastSearchResults = cachedResults
lastQueryKey = searchKey(bookUrl, query, replaceEnabled, regexReplace)
lastSearchSession = SearchSession(
bookUrl = bookUrl,
query = query,
replaceEnabled = replaceEnabled,
regexReplace = regexReplace,
results = cachedResults,
)
}
private fun searchKey(
bookUrl: String,
query: String,
@@ -34,6 +34,7 @@ import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.HttpTTS
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.exoplayer.InputStreamDataSource
import io.legado.app.help.http.okHttpClient
@@ -41,6 +42,7 @@ import io.legado.app.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.config.readConfig.ReadConfig
import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils
@@ -48,6 +50,7 @@ import io.legado.app.utils.printOnDebug
import io.legado.app.utils.servicePendingIntent
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
@@ -201,9 +204,38 @@ class HttpReadAloudService : BaseReadAloudService(),
}
}
// 辅助方法:确保能读到文件
private fun getChapterContent(book: Book, chapter: BookChapter): String? {
return BookHelp.getContent(book, chapter)
private suspend fun getPreDownloadChapter(
book: Book,
chapter: BookChapter,
): Pair<TextChapter, List<String>>? {
val content = BookHelp.getContent(book, chapter) ?: return null
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val displayTitle = chapter.getDisplayTitle(
contentProcessor.getTitleReplaceRules(),
book.getUseReplaceRule(),
)
val processedContent = contentProcessor.getContent(
book,
chapter,
content,
includeTitle = false,
)
val textChapter = ChapterProvider.getTextChapterAsync(
CoroutineScope(currentCoroutineContext()),
book,
chapter,
displayTitle,
processedContent,
ReadBook.simulatedChapterSize,
)
for (ignored in textChapter.layoutChannel) {
currentCoroutineContext().ensureActive()
}
val contentList = textChapter
.getNeedReadAloud(0, ReadConfig.readAloudByPage, 0)
.split("\n")
.filter { it.isNotEmpty() }
return textChapter to contentList
}
private suspend fun preDownloadAudios(httpTts: HttpTTS) {
@@ -218,25 +250,18 @@ class HttpReadAloudService : BaseReadAloudService(),
val targetIndex = currentIdx + i
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
// 1. 获取内容
val contentString = getChapterContent(book, chapter)
if (contentString.isNullOrEmpty()) continue // 内容没下载,跳过
val contentList = contentString.split("\n").filter { it.isNotEmpty() }
val (textChapter, contentList) =
getPreDownloadChapter(book, chapter) ?: continue
contentList.forEach { content ->
currentCoroutineContext().ensureActive()
// 2. 生成文件名:必须用 chapter.title (数据库原始标题)
val titleMd5 = MD5Utils.md5Encode16(chapter.title)
val contentMd5 = MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
val fileName = "${titleMd5}_${contentMd5}"
val fileName = md5SpeakFileName(content, textChapter)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
// 3. 文件不存在才下载
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
@@ -307,17 +332,12 @@ class HttpReadAloudService : BaseReadAloudService(),
val targetIndex = currentIdx + i
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
val contentString = getChapterContent(book, chapter)
if (contentString.isNullOrEmpty()) continue
val contentList = contentString.split("\n").filter { it.isNotEmpty() }
val (textChapter, contentList) =
getPreDownloadChapter(book, chapter) ?: continue
contentList.forEach { content ->
currentCoroutineContext().ensureActive()
// 同样使用数据库标题,保持一致
val titleMd5 = MD5Utils.md5Encode16(chapter.title)
val contentMd5 = MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
val fileName = "${titleMd5}_${contentMd5}"
val fileName = md5SpeakFileName(content, textChapter)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
@@ -95,7 +95,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -175,7 +174,6 @@ 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.text.AppText
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
@@ -2244,7 +2242,7 @@ private fun ReadMenuSlider(
) {
if (glassThumbEnabled && backdrop != null) {
ReadMenuLiquidSlider(
value = { value },
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
visibilityThreshold = 0.001f,
@@ -2272,7 +2270,7 @@ private fun ReadMenuSlider(
@Composable
private fun ReadMenuLiquidSlider(
value: () -> Float,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
visibilityThreshold: Float,
@@ -2303,7 +2301,7 @@ private fun ReadMenuLiquidSlider(
val dampedDragAnimation = remember(animationScope, trackWidth, rangeStart, rangeEnd, isLtr) {
DampedDragAnimation(
animationScope = animationScope,
initialValue = value(),
initialValue = value,
valueRange = valueRange,
visibilityThreshold = visibilityThreshold,
initialScale = 1f,
@@ -2333,13 +2331,10 @@ private fun ReadMenuLiquidSlider(
)
}
LaunchedEffect(dampedDragAnimation) {
snapshotFlow { value() }
.collectLatest { currentValue ->
if (dampedDragAnimation.targetValue != currentValue) {
dampedDragAnimation.updateValue(currentValue)
}
}
LaunchedEffect(dampedDragAnimation, value) {
if (dampedDragAnimation.targetValue != value) {
dampedDragAnimation.updateValue(value)
}
}
val progress = if (range == 0f) {
@@ -119,7 +119,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.net.URLEncoder
import kotlin.coroutines.coroutineContext
/**
@@ -849,12 +848,11 @@ class ReadBookViewModel(
is ReadBookIntent.ExportAllHttpTtsAsUrl -> {
execute {
val json = exportHttpTtsJson()
val url = uploadRepository.upload(
uploadRepository.upload(
fileName = "httpTTS.json",
file = json,
contentType = "application/json"
)
"legado://import/httpTTS?src=" + URLEncoder.encode(url, "UTF-8")
}.onSuccess { url ->
context.sendToClip(url)
_effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.copy_url)))
@@ -244,13 +244,18 @@ data class TextLine(
null
val lineY = height + (ReadBookConfig.durConfig.underlinePadding - 10).dpToPx()
val pageOffset = if (textPage.doublePage && !isLeftLine) {
ChapterProvider.viewWidth / 2f
} else {
0f
}
val startX = if (ReadBookConfig.underlineExtend) {
ChapterProvider.paddingLeft.toFloat()
pageOffset + ChapterProvider.paddingLeft
} else {
lineStart + indentWidth
}
val endX = if (ReadBookConfig.underlineExtend) {
(ChapterProvider.paddingLeft + ChapterProvider.visibleWidth).toFloat()
pageOffset + ChapterProvider.paddingLeft + ChapterProvider.visibleWidth
} else {
lineEnd
}
@@ -1057,6 +1057,10 @@ object ChapterProvider {
* 更新View尺寸
*/
fun upViewSize(width: Int, height: Int) {
upViewSizeRunnable?.let {
handler.removeCallbacks(it)
upViewSizeRunnable = null
}
if (width <= 0 || height <= 0) {
return
}
@@ -1069,9 +1073,6 @@ object ChapterProvider {
} else {
notifyViewSizeChange(width, height)
}
} else if (upViewSizeRunnable != null) {
handler.removeCallbacks(upViewSizeRunnable!!)
upViewSizeRunnable = null
}
}
@@ -96,12 +96,7 @@ fun ContentEditSheet(
AppModalBottomSheet(
show = show,
onDismissRequest = {
if (state.contentEditTitle.isNotEmpty()) {
onIntent(ReadBookIntent.SaveContentEdit(state.contentEditText, state.contentEditSaveToSource))
}
onDismissRequest()
},
onDismissRequest = onDismissRequest,
title = state.contentEditTitle,
) {
Column(
@@ -1,7 +1,10 @@
package io.legado.app.ui.book.read.sheet
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -11,6 +14,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.ConfigUpdate
@@ -39,7 +43,12 @@ fun ShadowSetSheet(
onDismissRequest = onDismissRequest,
title = stringResource(R.string.text_shadow_set),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
TinySwitchSettingItem(
title = stringResource(R.string.text_shadow_set),
checked = textShadow,
@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -52,6 +54,7 @@ fun UnderlineConfigSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
TinySwitchSettingItem(
@@ -45,13 +45,19 @@ class SearchContentViewModel(
private val searchContentRepository: SearchContentRepository
) : ViewModel() {
private val _searchQuery = MutableStateFlow(initialSearchWord ?: "")
private val restoredSession = if (initialSearchWord == null) {
searchContentRepository.getLastSession(bookUrl)
} else {
null
}
private val _searchQuery = MutableStateFlow(initialSearchWord ?: restoredSession?.query.orEmpty())
val searchQuery = _searchQuery.asStateFlow()
private val _replaceEnabled = MutableStateFlow(false)
private val _replaceEnabled = MutableStateFlow(restoredSession?.replaceEnabled ?: false)
val replaceEnabled = _replaceEnabled.asStateFlow()
private val _regexReplace = MutableStateFlow(false)
private val _regexReplace = MutableStateFlow(restoredSession?.regexReplace ?: false)
val regexReplace = _regexReplace.asStateFlow()
private val _uiState = MutableStateFlow(SearchUiState())
@@ -138,6 +144,7 @@ class SearchContentViewModel(
return
}
searchContentRepository.beginSearch(bookUrl, query, replace, regex)
searchJob = viewModelScope.launch {
_uiState.value.book?.let { book ->
saveSearchHistory(book, query)
@@ -0,0 +1,39 @@
package io.legado.app.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SearchContentRepositoryTest {
@Test
fun `beginSearch remembers latest search parameters for the book`() {
val repository = SearchContentRepository()
repository.beginSearch(
bookUrl = "book-1",
query = "keyword",
replaceEnabled = true,
regexReplace = false,
)
val session = repository.getLastSession("book-1")
assertEquals("keyword", session?.query)
assertEquals(true, session?.replaceEnabled)
assertEquals(false, session?.regexReplace)
assertEquals(emptyList<Any>(), session?.results)
}
@Test
fun `getLastSession does not return another book search`() {
val repository = SearchContentRepository()
repository.beginSearch(
bookUrl = "book-1",
query = "keyword",
replaceEnabled = false,
regexReplace = false,
)
assertNull(repository.getLastSession("book-2"))
}
}