From 70c88cf3f4e8234e93811220fc9158e56e372759 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:14:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=B9=A6=E7=B1=8D?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/ui/book/info/BookInfoContract.kt | 37 ++++- .../legado/app/ui/book/info/BookInfoScreen.kt | 68 +++++--- .../app/ui/book/info/BookInfoViewModel.kt | 149 +++++++++++++----- .../components/image/cover/CoilBookCover.kt | 6 +- 4 files changed, 192 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index ab4374158..8d02494f4 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.book.info import android.net.Uri +import androidx.compose.runtime.Stable import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource @@ -13,8 +14,8 @@ import kotlinx.collections.immutable.persistentListOf const val READER_RESULT_DELETED = 100 data class BookInfoUiState( - val book: Book? = null, - val chapterList: List = emptyList(), + val book: BookInfoBookUi? = null, + val hasChapters: Boolean = false, val webFiles: List = emptyList(), val kindLabels: List = emptyList(), val groupNames: String? = null, @@ -22,7 +23,7 @@ data class BookInfoUiState( val readRecordTotalTime: Long = 0L, val readRecordTimelineDays: List = emptyList(), val inBookshelf: Boolean = false, - val bookSource: BookSource? = null, + val bookSource: BookInfoSourceUi? = null, val relatedBooks: ImmutableList = persistentListOf(), val isTocLoading: Boolean = true, val isBusy: Boolean = false, @@ -33,11 +34,39 @@ data class BookInfoUiState( val dialog: BookInfoDialog? = null, ) +@Stable +data class BookInfoBookUi( + val bookUrl: String, + val name: String, + val author: String, + val realAuthor: String, + val origin: String, + val originName: String, + val coverPath: String?, + val group: Long, + val isLocal: Boolean, + val type: Int, + val canUpdate: Boolean, + val splitLongChapter: Boolean, + val durChapterTitle: String?, + val latestChapterTitle: String?, + val totalChapterNum: Int, + val durChapterIndex: Int, + val remark: String?, + val displayIntro: String?, +) + +@Stable +data class BookInfoSourceUi( + val sourceUrl: String, + val hasLogin: Boolean, +) + sealed interface BookInfoSheet { data object None : BookInfoSheet data object CoverPicker : BookInfoSheet data object GroupPicker : BookInfoSheet - data object SourcePicker : BookInfoSheet + data class SourcePicker(val oldBook: Book) : BookInfoSheet data object ReadRecord : BookInfoSheet data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet data class ArchiveEntries( diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index 87631b80b..a13836b20 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -64,12 +64,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.ImageLoader +import coil.size.Size import io.legado.app.R import io.legado.app.constant.BookType -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.SearchBook -import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.main.homepage.modules.BannerModule @@ -252,7 +250,7 @@ private fun BookInfoScreenContent( } BookInfoSummary( book = book, - chapterList = state.chapterList, + hasChapters = state.hasChapters, onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) }, ) } @@ -294,10 +292,10 @@ private fun BookInfoScreenContent( onConfirm = { onIntent(BookInfoIntent.SelectGroup(it)) }, ) } - BookInfoSheet.SourcePicker -> state.book?.let { book -> + is BookInfoSheet.SourcePicker -> { ChangeSourceSheet( - show = currentSheet == BookInfoSheet.SourcePicker, - oldBook = book, + show = currentSheet is BookInfoSheet.SourcePicker, + oldBook = sheet.oldBook, onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) }, onReplace = { source, newBook, toc, options -> onIntent(BookInfoIntent.ReplaceWithSource(source, newBook, toc, options)) @@ -419,12 +417,12 @@ private fun BookInfoTransparentTopAppBar( } @Composable -private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { - val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover" +private fun rememberBookInfoColorTheme(book: BookInfoBookUi?): ThemeOverrideState? { + val useDefaultCover = AppConfig.useDefaultCover || book?.coverPath == "use_default_cover" if (useDefaultCover) return null val imageLoader = koinInject() - val coverPath = book?.getDisplayCover() + val coverPath = book?.coverPath val sourceOrigin = book?.origin val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { @@ -480,8 +478,21 @@ private fun BookInfoTopBarActions( @Composable private fun BookInfoBackdrop( - book: Book, + book: BookInfoBookUi, ) { + val backdropState = remember( + book.name, + book.author, + book.coverPath, + book.origin, + ) { + BookInfoBackdropState( + name = book.name, + author = book.author, + coverPath = book.coverPath, + sourceOrigin = book.origin, + ) + } val seedOverlay = lerp( LegadoTheme.colorScheme.secondaryContainer, LegadoTheme.seedColor, @@ -489,21 +500,25 @@ private fun BookInfoBackdrop( ) Box(modifier = Modifier.fillMaxSize()) { Crossfade( - targetState = book, + targetState = backdropState, animationSpec = tween(800), label = "BackdropCrossfade" ) { currentBook -> BookCoverImage( name = currentBook.name, author = currentBook.author, - path = currentBook.getDisplayCover(), - sourceOrigin = currentBook.origin, + path = currentBook.coverPath, + sourceOrigin = currentBook.sourceOrigin, + memoryCacheKey = currentBook.coverPath?.let { "$it#book-info-backdrop" }, modifier = Modifier .fillMaxWidth() .height(480.dp) .blur(24.dp), contentScale = ContentScale.Crop, showLoadingPlaceholder = false, + requestBuilder = { + size(Size(384, 384)) + } ) } Box( @@ -530,6 +545,13 @@ private fun BookInfoBackdrop( } } +private data class BookInfoBackdropState( + val name: String, + val author: String, + val coverPath: String?, + val sourceOrigin: String?, +) + @Composable private fun BookInfoOverflowMenu( expanded: Boolean, @@ -563,7 +585,7 @@ private fun BookInfoOverflowMenu( onClick = { onMenuAction(BookInfoMenuAction.Upload) } ) } - if (!state.bookSource?.loginUrl.isNullOrBlank()) { + if (state.bookSource?.hasLogin == true) { RoundDropdownMenuItem( text = stringResource(R.string.login), onClick = { onMenuAction(BookInfoMenuAction.Login) } @@ -602,7 +624,7 @@ private fun BookInfoOverflowMenu( RoundDropdownMenuItem( text = stringResource(R.string.split_long_chapter), onClick = { onMenuAction(BookInfoMenuAction.ToggleSplitLongChapter) }, - isSelected = book.getSplitLongChapter() + isSelected = book.splitLongChapter ) } RoundDropdownMenuItem( @@ -623,7 +645,7 @@ private fun BookInfoOverflowMenu( @Composable private fun BookInfoHeader( - book: Book, + book: BookInfoBookUi, kindLabels: List, groupNames: String?, onCoverClick: () -> Unit, @@ -668,7 +690,7 @@ private fun BookInfoHeader( CoilBookCover( name = book.name, author = book.author, - path = book.getDisplayCover(), + path = book.coverPath, sourceOrigin = book.origin, modifier = Modifier .width(112.dp) @@ -720,7 +742,7 @@ private fun BookInfoHeader( } } AnimatedTextLine( - text = stringResource(R.string.author_show, book.getRealAuthor()), + text = stringResource(R.string.author_show, book.realAuthor), style = LegadoTheme.typography.bodyLarge, color = LegadoTheme.colorScheme.onSurfaceVariant, modifier = Modifier.combinedClickable( @@ -886,8 +908,8 @@ private fun BookInfoActionCard( @Composable private fun BookInfoSummary( - book: Book, - chapterList: List, + book: BookInfoBookUi, + hasChapters: Boolean, onRemarkClick: () -> Unit, ) { Column( @@ -928,7 +950,7 @@ private fun BookInfoSummary( color = LegadoTheme.colorScheme.secondary, ) } - if (chapterList.isEmpty()) { + if (!hasChapters) { AnimatedTextLine( text = stringResource(R.string.error_load_toc), style = LegadoTheme.typography.bodySmall, @@ -952,7 +974,7 @@ private fun BookInfoSummary( } Spacer(modifier = Modifier.height(4.dp)) AnimatedTextLine( - text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) }, + text = book.displayIntro.orEmpty().ifBlank { stringResource(R.string.intro_show_null) }, style = LegadoTheme.typography.bodyMedium, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index 852c470b8..c2374e2a0 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -62,12 +62,14 @@ import io.legado.app.utils.postEvent import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toastOnUi import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream class BookInfoViewModel( @@ -119,6 +122,7 @@ class BookInfoViewModel( private var changeSourceCoroutine: Coroutine<*>? = null private var readRecordObserveJob: Job? = null + private var relatedBooksLoadJob: Job? = null fun initData(intent: Intent) { initData( @@ -162,6 +166,7 @@ class BookInfoViewModel( bookSource = null chapterChanged = false clearReadRecordObserve() + relatedBooksLoadJob?.cancel() syncUiState() execute { val dbBook = appDb.bookDao.getBook(bookUrl) @@ -214,7 +219,8 @@ class BookInfoViewModel( ?.let { showDialog(BookInfoDialog.PhotoPreview(it)) } BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker) - BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker) + BookInfoIntent.ChangeSourceClick -> currentBook?.uiCopy() + ?.let { setSheet(BookInfoSheet.SourcePicker(it)) } BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord) BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark)) is BookInfoIntent.SaveCover -> { @@ -733,7 +739,7 @@ class BookInfoViewModel( } else { loadChapter(loadedBook, runPreUpdateJs) } - loadRelatedBooks(loadedBook, source) + scheduleRelatedBooksLoad(loadedBook, source) }.onError { AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) context.toastOnUi(R.string.error_get_book_info) @@ -792,7 +798,7 @@ class BookInfoViewModel( if (chapters.isNotEmpty()) { currentChapterList = chapters syncUiState(isTocLoading = false) - source?.let { loadRelatedBooks(book, it) } + source?.let { scheduleRelatedBooksLoad(book, it) } } else { loadChapter(book) } @@ -1291,8 +1297,8 @@ class BookInfoViewModel( private fun syncUiState(isTocLoading: Boolean = _uiState.value.isTocLoading) { _uiState.update { it.copy( - book = currentBook?.uiCopy(), - chapterList = currentChapterList, + book = currentBook?.toBookInfoBookUi(), + hasChapters = currentChapterList.isNotEmpty(), webFiles = currentWebFiles, relatedBooks = currentRelatedBooks.toImmutableList(), kindLabels = currentKindLabels, @@ -1301,7 +1307,7 @@ class BookInfoViewModel( readRecordTotalTime = currentReadRecordTotalTime, readRecordTimelineDays = currentReadRecordTimelineDays, inBookshelf = inBookshelf, - bookSource = bookSource, + bookSource = bookSource?.toBookInfoSourceUi(), isTocLoading = isTocLoading, deleteAlertEnabled = LocalConfig.bookInfoDeleteAlert, deleteOriginal = LocalConfig.deleteBookOriginal, @@ -1332,14 +1338,50 @@ class BookInfoViewModel( ) } - private fun loadRelatedBooks(book: Book, source: BookSource) { + private fun scheduleRelatedBooksLoad( + book: Book, + source: BookSource, + delayMillis: Long = 350L, + ) { + relatedBooksLoadJob?.cancel() + relatedBooksLoadJob = viewModelScope.launch { + delay(delayMillis) + if (!isCurrentBookSource(book, source)) return@launch + + val modules = parseRelatedBookModules(source) + if (modules.isEmpty()) { + currentRelatedBooks = emptyList() + syncUiState() + return@launch + } + + try { + val result = withContext(IO) { + loadRelatedBooks(book, source, modules) + } + if (!isCurrentBookSource(book, source)) return@launch + currentRelatedBooks = result + syncUiState() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (!isCurrentBookSource(book, source)) return@launch + currentRelatedBooks = emptyList() + syncUiState() + } + } + } + + private fun isCurrentBookSource(book: Book, source: BookSource): Boolean { + return currentBook?.bookUrl == book.bookUrl && bookSource?.bookSourceUrl == source.bookSourceUrl + } + + private fun parseRelatedBookModules(source: BookSource): List { val modulesJson = source.ruleBookInfo?.relatedBooks if (modulesJson.isNullOrBlank()) { - currentRelatedBooks = emptyList() - syncUiState() - return + return emptyList() } - val modules = try { + return try { GSON.fromJsonArray(modulesJson) .getOrNull() ?.filter { !it.url.isNullOrBlank() } @@ -1348,36 +1390,33 @@ class BookInfoViewModel( } catch (e: Exception) { emptyList() } - if (modules.isEmpty()) { - currentRelatedBooks = emptyList() - syncUiState() - return - } - execute { - coroutineScope { - modules.map { def -> - async { - val (resolvedUrl, books) = try { - resolveAndExplore(source, def.url!!, book) - } catch (e: Exception) { - def.url!! to emptyList() - } - RelatedBooksUi( - key = def.key ?: def.title.orEmpty(), - title = def.title.orEmpty(), - url = def.url!!, - resolvedUrl = resolvedUrl, - books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(), - ) + } + + private suspend fun loadRelatedBooks( + book: Book, + source: BookSource, + modules: List, + ): List { + return coroutineScope { + modules.map { def -> + async { + val url = def.url.orEmpty() + val (resolvedUrl, books) = try { + resolveAndExplore(source, url, book) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + url to emptyList() } - }.awaitAll().filter { it.books.isNotEmpty() } - } - }.onSuccess { result -> - currentRelatedBooks = result - syncUiState() - }.onError { - currentRelatedBooks = emptyList() - syncUiState() + RelatedBooksUi( + key = def.key ?: def.title.orEmpty(), + title = def.title.orEmpty(), + url = url, + resolvedUrl = resolvedUrl, + books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(), + ) + } + }.awaitAll().filter { it.books.isNotEmpty() } } } @@ -1393,6 +1432,36 @@ class BookInfoViewModel( _effects.tryEmit(effect) } + private fun Book.toBookInfoBookUi(): BookInfoBookUi { + return BookInfoBookUi( + bookUrl = bookUrl, + name = name, + author = author, + realAuthor = getRealAuthor(), + origin = origin, + originName = originName, + coverPath = getDisplayCover(), + group = group, + isLocal = isLocal, + type = type, + canUpdate = canUpdate, + splitLongChapter = getSplitLongChapter(), + durChapterTitle = durChapterTitle, + latestChapterTitle = latestChapterTitle, + totalChapterNum = totalChapterNum, + durChapterIndex = durChapterIndex, + remark = remark, + displayIntro = getDisplayIntro(), + ) + } + + private fun BookSource.toBookInfoSourceUi(): BookInfoSourceUi { + return BookInfoSourceUi( + sourceUrl = bookSourceUrl, + hasLogin = !loginUrl.isNullOrBlank(), + ) + } + private fun Book.uiCopy(): Book { return copy().also { snapshot -> snapshot.infoHtml = infoHtml diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index c511e5f9a..b3b4f0d86 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.graphics.withSave import coil.compose.AsyncImage +import coil.request.ImageRequest import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.theme.LegadoTheme import org.koin.compose.koinInject @@ -58,6 +59,7 @@ fun BookCoverImage( path: String?, modifier: Modifier = Modifier, sourceOrigin: String? = null, + memoryCacheKey: String? = null, ignoreUseDefaultCover: Boolean = false, showLoadingPlaceholder: Boolean = true, contentScale: ContentScale = ContentScale.Crop, @@ -65,6 +67,7 @@ fun BookCoverImage( onSuccess: (() -> Unit)? = null, onError: (() -> Unit)? = null, sharedCoverKey: String? = null, + requestBuilder: ImageRequest.Builder.() -> Unit = {}, ) { val context = LocalContext.current val isNight = LegadoTheme.isDark @@ -116,7 +119,8 @@ fun BookCoverImage( sourceOrigin = sourceOrigin, loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, crossfade = showLoadingPlaceholder, - memoryCacheKey = finalPath, + memoryCacheKey = memoryCacheKey ?: finalPath, + configure = requestBuilder, ), contentDescription = null, imageLoader = koinInject(),