fix: 修复阅读界面功能问题
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user