From 97676113ef5aef54de87c02b31c49b2923b44b0f Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Mon, 27 Apr 2026 00:57:20 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E7=BA=BF=E7=A8=8B=E8=B0=83=E6=95=B4=E5=92=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E5=8F=AF=E4=BB=A5=E7=AE=A1=E7=90=86=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B9=A6=E7=B1=8D=E5=92=8C=E7=AB=A0=E8=8A=82=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/legado/app/constant/PreferKey.kt | 1 + .../main/java/io/legado/app/di/appModule.kt | 2 + .../java/io/legado/app/model/CacheBook.kt | 10 +- .../io/legado/app/service/CacheBookService.kt | 2 +- .../cache/manage/BookCacheManageScreen.kt | 443 ++++++++++++++++++ .../cache/manage/BookCacheManageViewModel.kt | 264 +++++++++++ .../app/ui/config/otherConfig/OtherConfig.kt | 5 + .../config/otherConfig/OtherConfigScreen.kt | 9 + .../io/legado/app/ui/main/MainActivity.kt | 24 +- .../java/io/legado/app/ui/main/MainScreen.kt | 8 +- .../java/io/legado/app/ui/main/my/MyScreen.kt | 8 + .../io/legado/app/ui/main/my/MyViewModel.kt | 1 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 14 files changed, 776 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index eb41c5028..527b43f0c 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -66,6 +66,7 @@ object PreferKey { const val backupPath = "backupUri" const val restoreIgnore = "restoreIgnore" const val threadCount = "threadCount" + const val cacheBookThreadCount = "cacheBookThreadCount" const val webPort = "webPort" const val keepLight = "keep_light" const val readSliderMode = "read_slider_mode" diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 38ad5656e..8fb48426c 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -55,6 +55,7 @@ import io.legado.app.help.coil.CoverInterceptor import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClientManga import io.legado.app.ui.book.bookmark.AllBookmarkViewModel +import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel import io.legado.app.ui.book.changecover.ChangeCoverViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel @@ -189,6 +190,7 @@ val appModule = module { viewModelOf(::ExploreViewModel) viewModelOf(::RssViewModel) viewModelOf(::SearchViewModel) + viewModelOf(::BookCacheManageViewModel) viewModel { BookshelfManageScreenViewModel( application = get(), diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 15340c483..12a093f10 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -77,7 +77,7 @@ object CacheBook { }.onStart { postEvent(EventBus.UP_DOWNLOAD_STATE, "") updateSummary() - }.onEachParallel(OtherConfig.threadCount.coerceAtLeast(1)) { + }.onEachParallel(OtherConfig.cacheBookThreadCount.coerceAtLeast(1)) { coroutineScope { it.download(this, context) } @@ -214,7 +214,7 @@ object CacheBook { val downloadSummary: String get() { val stats = collectQueueStats() - return "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}" + return "正在下载:${stats.downloadingCount} | 等待中:${stats.waitingCount} | 失败:${errorDownloadMap.size} | 成功:${successDownloadSet.size}" } val isRun: Boolean @@ -259,6 +259,12 @@ object CacheBook { @Synchronized fun queueCounts(): Pair = waitDownloadSet.size to onDownloadSet.size + @Synchronized + fun waitingIndices(): Set = waitDownloadSet.toSet() + + @Synchronized + fun downloadingIndices(): Set = onDownloadSet.toSet() + @Synchronized fun isRun(): Boolean { return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt index f100947a6..afe057b48 100644 --- a/app/src/main/java/io/legado/app/service/CacheBookService.kt +++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt @@ -41,7 +41,7 @@ class CacheBookService : BaseService() { private set } - private val threadCount = OtherConfig.threadCount + private val threadCount = OtherConfig.cacheBookThreadCount private var cachePool = Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() private var downloadJob: Job? = null diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt new file mode 100644 index 000000000..0fa693f90 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt @@ -0,0 +1,443 @@ +package io.legado.app.ui.book.cache.manage + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppLinearProgressIndicator +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.SmallTonalIconButton +import io.legado.app.ui.widget.components.button.TopBarActionButton +import io.legado.app.ui.widget.components.button.TopBarNavigationButton +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.utils.toastOnUi +import org.koin.androidx.compose.koinViewModel + +@Composable +fun BookCacheManageRouteScreen( + onBackClick: () -> Unit, + viewModel: BookCacheManageViewModel = koinViewModel() +) { + LaunchedEffect(Unit) { + viewModel.onIntent(BookCacheManageIntent.Initialize) + } + val context = LocalContext.current + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is BookCacheManageEffect.ShowMessage -> context.toastOnUi(effect.message) + } + } + } + val state by viewModel.uiState.collectAsStateWithLifecycle() + BookCacheManageScreen( + state = state, + onBackClick = onBackClick, + onIntent = viewModel::onIntent + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BookCacheManageScreen( + state: BookCacheManageUiState, + onBackClick: () -> Unit, + onIntent: (BookCacheManageIntent) -> Unit, +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + var pendingDeleteBook by remember { mutableStateOf(null) } + var pendingDeleteChapter by remember { mutableStateOf?>(null) } + var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet()) } + val allBooks = state.shelfBooks + state.notShelfBooks + val hasRunningDownload = allBooks.any { it.isDownloading } + val hasDownloadTarget = allBooks.any { it.chapters.any { chapter -> !chapter.isCached } } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = "缓存管理", + subtitle = state.downloadSummary.takeIf { it.isNotBlank() }, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + }, + actions = { + TopBarActionButton( + onClick = { onIntent(BookCacheManageIntent.Refresh) }, + imageVector = Icons.Default.Refresh, + contentDescription = "刷新" + ) + }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + if (hasRunningDownload || hasDownloadTarget) { + FloatingActionButton( + onClick = { + if (hasRunningDownload) { + onIntent(BookCacheManageIntent.StopAllDownloads) + } else { + onIntent(BookCacheManageIntent.StartAllDownloads) + } + } + ) { + AppIcon( + imageVector = if (hasRunningDownload) Icons.Default.Stop else Icons.Default.Download, + contentDescription = if (hasRunningDownload) "停止下载" else "开始下载" + ) + } + } + } + ) { paddingValues -> + if (state.isLoading) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + 24.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + cacheSection( + title = "书架书籍", + emptyText = "没有书架内书籍缓存或下载任务", + books = state.shelfBooks, + expandedBookUrls = expandedBookUrls, + onToggleExpanded = { bookUrl -> + expandedBookUrls = expandedBookUrls.toggle(bookUrl) + }, + onIntent = onIntent, + onDeleteBook = { pendingDeleteBook = it }, + onDeleteChapter = { book, chapter -> pendingDeleteChapter = book to chapter } + ) + cacheSection( + title = "未在书架", + emptyText = "没有未在书架的书籍下载状态", + books = state.notShelfBooks, + expandedBookUrls = expandedBookUrls, + onToggleExpanded = { bookUrl -> + expandedBookUrls = expandedBookUrls.toggle(bookUrl) + }, + onIntent = onIntent, + onDeleteBook = { pendingDeleteBook = it }, + onDeleteChapter = { book, chapter -> pendingDeleteChapter = book to chapter } + ) + } + } + } + + DeleteBookCacheDialog( + item = pendingDeleteBook, + onConfirm = { item -> + onIntent(BookCacheManageIntent.DeleteBookCache(item.book.bookUrl)) + pendingDeleteBook = null + }, + onDismiss = { pendingDeleteBook = null } + ) + DeleteChapterCacheDialog( + item = pendingDeleteChapter, + onConfirm = { book, chapter -> + onIntent( + BookCacheManageIntent.DeleteChapterCache( + book.book.bookUrl, + chapter.chapter.url + ) + ) + pendingDeleteChapter = null + }, + onDismiss = { pendingDeleteChapter = null } + ) +} + +private fun LazyListScope.cacheSection( + title: String, + emptyText: String, + books: List, + expandedBookUrls: Set, + onToggleExpanded: (String) -> Unit, + onIntent: (BookCacheManageIntent) -> Unit, + onDeleteBook: (BookCacheBookItem) -> Unit, + onDeleteChapter: (BookCacheBookItem, BookCacheChapterItem) -> Unit, +) { + item(key = "$title-header") { + AppText( + text = title, + modifier = Modifier.padding(start = 4.dp, top = 12.dp, bottom = 4.dp), + style = LegadoTheme.typography.titleSmallEmphasized, + color = LegadoTheme.colorScheme.primary + ) + } + if (books.isEmpty()) { + item(key = "$title-empty") { + TextCard(text = emptyText, modifier = Modifier.fillMaxWidth()) + } + } else { + books.forEach { item -> + val bookUrl = item.book.bookUrl + val expanded = expandedBookUrls.contains(bookUrl) + item(key = "$title-book-$bookUrl") { + BookCacheBookCard( + item = item, + expanded = expanded, + onToggleExpanded = { onToggleExpanded(bookUrl) }, + onIntent = onIntent, + onDeleteBook = onDeleteBook, + modifier = Modifier.animateItem() + ) + } + if (expanded) { + items( + items = item.chapters, + key = { chapter -> "$title-chapter-$bookUrl-${chapter.chapter.url}" } + ) { chapter -> + BookCacheChapterRow( + item = chapter, + modifier = Modifier.animateItem(), + onDownload = { + onIntent( + BookCacheManageIntent.DownloadChapter( + bookUrl, + chapter.chapter.index + ) + ) + }, + onDelete = { onDeleteChapter(item, chapter) } + ) + } + } + } + } +} + +@Composable +private fun BookCacheBookCard( + item: BookCacheBookItem, + expanded: Boolean, + onToggleExpanded: () -> Unit, + onIntent: (BookCacheManageIntent) -> Unit, + onDeleteBook: (BookCacheBookItem) -> Unit, + modifier: Modifier = Modifier, +) { + val arrowRotation by animateFloatAsState( + targetValue = if (expanded) 90f else 0f, + label = "BookCacheExpandArrow" + ) + NormalCard( + modifier = modifier.fillMaxWidth(), + onClick = onToggleExpanded, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppIcon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier + .size(20.dp) + .graphicsLayer(rotationZ = arrowRotation) + ) + Column(modifier = Modifier.weight(1f)) { + AppText( + text = item.book.name, + style = LegadoTheme.typography.titleSmallEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + AppText( + text = item.book.getRealAuthor(), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextCard(text = "${item.cachedCount}/${item.totalCount}") + } + AppLinearProgressIndicator( + progress = item.progress, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppText( + text = "下载中 ${item.downloadingCount} · 等待 ${item.waitingCount} · 失败 ${item.errorCount}", + modifier = Modifier.weight(1f), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant + ) + SmallTonalIconButton( + onClick = { onDeleteBook(item) }, + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } + } + } +} + +@Composable +private fun BookCacheChapterRow( + item: BookCacheChapterItem, + modifier: Modifier = Modifier, + onDownload: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + AppText( + text = item.chapter.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LegadoTheme.typography.bodyMedium + ) + AppText( + text = chapterStatusText(item), + maxLines = 1, + style = LegadoTheme.typography.labelSmall, + color = if (item.isError) { + LegadoTheme.colorScheme.error + } else if (item.isCached) { + LegadoTheme.colorScheme.primary + } else { + LegadoTheme.colorScheme.onSurfaceVariant + } + ) + } + SmallTonalIconButton( + onClick = onDownload, + imageVector = Icons.Default.Download, + contentDescription = null + ) + SmallTonalIconButton( + onClick = onDelete, + imageVector = Icons.Default.Delete, + contentDescription = null + ) + } +} + +private fun Set.toggle(value: String): Set { + return if (contains(value)) this - value else this + value +} + +private fun chapterStatusText(item: BookCacheChapterItem): String { + return when { + item.isDownloading -> "下载中" + item.isWaiting -> "等待下载" + item.isError -> "下载失败" + item.isCached -> "已缓存" + else -> "未缓存" + } +} + +@Composable +private fun DeleteBookCacheDialog( + item: BookCacheBookItem?, + onConfirm: (BookCacheBookItem) -> Unit, + onDismiss: () -> Unit, +) { + AppAlertDialog( + show = item != null, + onDismissRequest = onDismiss, + title = stringResource(R.string.delete), + text = "删除《${item?.book?.name.orEmpty()}》的全部缓存,并从下载队列移除?", + confirmText = stringResource(android.R.string.ok), + onConfirm = { item?.let(onConfirm) }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = onDismiss + ) +} + +@Composable +private fun DeleteChapterCacheDialog( + item: Pair?, + onConfirm: (BookCacheBookItem, BookCacheChapterItem) -> Unit, + onDismiss: () -> Unit, +) { + AppAlertDialog( + show = item != null, + onDismissRequest = onDismiss, + title = stringResource(R.string.delete), + text = "删除章节缓存:${item?.second?.chapter?.title.orEmpty()}?", + confirmText = stringResource(android.R.string.ok), + onConfirm = { item?.let { onConfirm(it.first, it.second) } }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = onDismiss + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt new file mode 100644 index 000000000..1bff8fac0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt @@ -0,0 +1,264 @@ +package io.legado.app.ui.book.cache.manage + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.dao.BookChapterDao +import io.legado.app.data.dao.BookDao +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.domain.usecase.CacheBookChaptersUseCase +import io.legado.app.domain.usecase.ClearBookCacheUseCase +import io.legado.app.help.book.BookHelp +import io.legado.app.help.book.isAudio +import io.legado.app.help.book.isLocal +import io.legado.app.help.book.isNotShelf +import io.legado.app.model.CacheBook +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class BookCacheManageUiState( + val isLoading: Boolean = true, + val shelfBooks: List = emptyList(), + val notShelfBooks: List = emptyList(), + val downloadSummary: String = CacheBook.downloadSummary, + val version: Long = 0, +) + +data class BookCacheBookItem( + val book: Book, + val chapters: List, + val cachedCount: Int, + val waitingCount: Int, + val downloadingCount: Int, + val errorCount: Int, + val isNotShelf: Boolean, +) { + val totalCount: Int get() = chapters.size + val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount + val isDownloading: Boolean get() = waitingCount > 0 || downloadingCount > 0 +} + +data class BookCacheChapterItem( + val chapter: BookChapter, + val isCached: Boolean, + val isWaiting: Boolean, + val isDownloading: Boolean, + val isError: Boolean, +) + +sealed interface BookCacheManageIntent { + data object Initialize : BookCacheManageIntent + data object Refresh : BookCacheManageIntent + data object StartAllDownloads : BookCacheManageIntent + data object StopAllDownloads : BookCacheManageIntent + data class DeleteBookCache(val bookUrl: String) : BookCacheManageIntent + data class DownloadChapter(val bookUrl: String, val chapterIndex: Int) : BookCacheManageIntent + data class DeleteChapterCache(val bookUrl: String, val chapterUrl: String) : BookCacheManageIntent +} + +sealed interface BookCacheManageEffect { + data class ShowMessage(val message: String) : BookCacheManageEffect +} + +class BookCacheManageViewModel( + application: Application, + private val bookDao: BookDao, + private val bookChapterDao: BookChapterDao, + private val cacheBookChaptersUseCase: CacheBookChaptersUseCase, + private val clearBookCacheUseCase: ClearBookCacheUseCase, +) : BaseViewModel(application) { + + private val _uiState = MutableStateFlow(BookCacheManageUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 16) + val effects = _effects.asSharedFlow() + + private var observeJob: Job? = null + private val chapterCache = hashMapOf>() + + fun onIntent(intent: BookCacheManageIntent) { + when (intent) { + BookCacheManageIntent.Initialize -> initialize() + BookCacheManageIntent.Refresh -> reload() + BookCacheManageIntent.StartAllDownloads -> startAllDownloads() + BookCacheManageIntent.StopAllDownloads -> stopAllDownloads() + is BookCacheManageIntent.DeleteBookCache -> deleteBookCache(intent.bookUrl) + is BookCacheManageIntent.DownloadChapter -> downloadChapter( + intent.bookUrl, + intent.chapterIndex + ) + is BookCacheManageIntent.DeleteChapterCache -> deleteChapterCache( + intent.bookUrl, + intent.chapterUrl + ) + } + } + + private fun initialize() { + if (observeJob != null) return + observeJob = viewModelScope.launch { + bookDao.flowAll().collect { + reload(it) + } + } + viewModelScope.launch { + CacheBook.cacheSuccessFlow.collect { + reload() + } + } + viewModelScope.launch { + CacheBook.downloadingIndicesFlow.collect { + reload() + } + } + viewModelScope.launch { + CacheBook.downloadErrorFlow.collect { + reload() + } + } + viewModelScope.launch { + CacheBook.downloadSummaryFlow.collect { summary -> + _uiState.update { + it.copy(downloadSummary = summary, version = it.version + 1) + } + reload() + } + } + } + + private fun reload(books: List? = null) { + viewModelScope.launch(Dispatchers.IO) { + val sourceBooks = books ?: bookDao.all + val items = sourceBooks + .filterNot { it.isLocal || it.isAudio } + .mapNotNull { book -> buildBookItem(book) } + .filter { item -> + item.cachedCount > 0 || item.isDownloading || item.errorCount > 0 + } + .sortedWith(compareByDescending { it.isDownloading } + .thenByDescending { it.cachedCount } + .thenBy { it.book.name }) + withContext(Dispatchers.Main) { + _uiState.update { + it.copy( + isLoading = false, + shelfBooks = items.filterNot { item -> item.isNotShelf }, + notShelfBooks = items.filter { item -> item.isNotShelf }, + downloadSummary = CacheBook.downloadSummary, + version = it.version + 1, + ) + } + } + } + } + + private fun buildBookItem(book: Book): BookCacheBookItem? { + val chapters = chapterCache.getOrPut(book.bookUrl) { + bookChapterDao.getChapterList(book.bookUrl) + } + val cacheFiles = BookHelp.getChapterFiles(book) + val model = CacheBook.cacheBookMap[book.bookUrl] + val waitingIndices = model?.waitingIndices().orEmpty() + val downloadingIndices = model?.downloadingIndices().orEmpty() + val errorIndices = CacheBook.downloadErrorFlow.value + .takeIf { it.first == book.bookUrl } + ?.second + .orEmpty() + if (chapters.isEmpty() && cacheFiles.isEmpty() && model == null && !book.isNotShelf) { + return null + } + val chapterItems = chapters.map { chapter -> + BookCacheChapterItem( + chapter = chapter, + isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume, + isWaiting = waitingIndices.contains(chapter.index), + isDownloading = downloadingIndices.contains(chapter.index), + isError = errorIndices.contains(chapter.index), + ) + } + return BookCacheBookItem( + book = book, + chapters = chapterItems, + cachedCount = chapterItems.count { it.isCached }, + waitingCount = waitingIndices.size, + downloadingCount = downloadingIndices.size, + errorCount = errorIndices.size, + isNotShelf = book.isNotShelf, + ) + } + + private fun stopAllDownloads() { + CacheBook.stop(context) + reload() + } + + private fun startAllDownloads() { + val items = uiState.value.shelfBooks + uiState.value.notShelfBooks + execute { + items.sumOf { item -> + val chapterIndices = item.chapters + .filterNot { it.isCached || it.isWaiting || it.isDownloading } + .map { it.chapter.index } + cacheBookChaptersUseCase.execute(item.book.bookUrl, chapterIndices) + } + }.onSuccess { count -> + if (count > 0) { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("已加入缓存队列: $count 章")) + } else { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("没有可缓存的章节")) + } + }.onError { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("加入缓存队列失败\n${it.localizedMessage}")) + }.onFinally { + reload() + } + } + + private fun deleteBookCache(bookUrl: String) { + CacheBook.remove(context, bookUrl) + execute { + clearBookCacheUseCase.execute(bookUrl) + }.onSuccess { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("缓存已删除")) + }.onError { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除缓存失败\n${it.localizedMessage}")) + }.onFinally { + reload() + } + } + + private fun downloadChapter(bookUrl: String, chapterIndex: Int) { + execute { + cacheBookChaptersUseCase.execute(bookUrl, listOf(chapterIndex)) + }.onSuccess { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节已加入缓存队列")) + }.onError { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存失败\n${it.localizedMessage}")) + }.onFinally { + reload() + } + } + + private fun deleteChapterCache(bookUrl: String, chapterUrl: String) { + val book = bookDao.getBook(bookUrl) ?: return + val chapter = chapterCache[bookUrl]?.firstOrNull { it.url == chapterUrl } ?: return + execute { + BookHelp.delContent(book, chapter) + }.onSuccess { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("章节缓存已删除")) + }.onError { + _effects.tryEmit(BookCacheManageEffect.ShowMessage("删除章节缓存失败\n${it.localizedMessage}")) + }.onFinally { + reload() + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt index dc643b0fb..847d29c30 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt @@ -160,6 +160,11 @@ object OtherConfig { 16 ) + var cacheBookThreadCount by prefDelegate( + PreferKey.cacheBookThreadCount, + 16 + ) + var processText by prefDelegate( PreferKey.processText, true diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt index c25d7b222..1fe15817d 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt @@ -372,6 +372,15 @@ fun OtherConfigScreen( onValueChange = { OtherConfig.threadCount = it.toInt() } ) + SliderSettingItem( + title = stringResource(R.string.cache_book_threads_num_title), + description = stringResource(R.string.cache_book_threads_num_summary), + value = OtherConfig.cacheBookThreadCount.toFloat(), + defaultValue = 16f, + valueRange = 1f..256f, + onValueChange = { OtherConfig.cacheBookThreadCount = it.toInt() } + ) + SwitchSettingItem( title = stringResource(R.string.add_to_text_context_menu_t), description = stringResource(R.string.add_to_text_context_menu_s), diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 1cc6a8127..caace31cb 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -40,6 +40,7 @@ import io.legado.app.lib.dialogs.alert import io.legado.app.service.WebService import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.UpdateDialog +import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.import.local.ImportBookScreen import io.legado.app.ui.book.import.remote.RemoteBookScreen @@ -93,6 +94,7 @@ open class MainActivity : BaseComposeActivity() { private const val ROUTE_IMPORT_LOCAL = "import/local" private const val ROUTE_IMPORT_REMOTE = "import/remote" private const val ROUTE_CACHE = "cache" + private const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage" private const val ROUTE_SEARCH = "search" private const val ROUTE_RSS_SORT = "rss/sort" private const val ROUTE_RSS_READ = "rss/read" @@ -176,6 +178,12 @@ open class MainActivity : BaseComposeActivity() { groupId: Long = -1L ): Intent = createBookshelfManageScreenIntent(context, groupId) + fun createBookCacheManageIntent(context: Context): Intent { + return createLauncherIntent(context).apply { + putExtra(EXTRA_START_ROUTE, ROUTE_BOOK_CACHE_MANAGE) + } + } + fun createSearchIntent( context: Context, key: String? = null, @@ -236,6 +244,9 @@ open class MainActivity : BaseComposeActivity() { @Serializable private data class MainRouteCache(val groupId: Long) : MainRoute + @Serializable + private data object MainRouteBookCacheManage : MainRoute + @Serializable private data class MainRouteSearch( val key: String?, @@ -400,6 +411,9 @@ open class MainActivity : BaseComposeActivity() { onNavigateToCache = { groupId -> navigateToRoute(backStack, MainRouteCache(groupId)) }, + onNavigateToBookCacheManage = { + navigateToRoute(backStack, MainRouteBookCacheManage) + }, onNavigateToRssSort = { sourceUrl, sortUrl, key -> navigateToRoute( backStack, @@ -474,6 +488,12 @@ open class MainActivity : BaseComposeActivity() { ) } + entry { + BookCacheManageRouteScreen( + onBackClick = { navigateBack(backStack) } + ) + } + entry { route -> val searchViewModel = koinViewModel() val lifecycleOwner = LocalLifecycleOwner.current @@ -590,7 +610,8 @@ open class MainActivity : BaseComposeActivity() { MainRouteImportLocal, MainRouteImportRemote, - is MainRouteCache -> { + is MainRouteCache, + MainRouteBookCacheManage -> { if (currentRoute == MainRouteHome) { backStack.add(route) } else { @@ -801,6 +822,7 @@ open class MainActivity : BaseComposeActivity() { ROUTE_IMPORT_LOCAL -> MainRouteImportLocal ROUTE_IMPORT_REMOTE -> MainRouteImportRemote ROUTE_CACHE -> MainRouteCache(intent?.getLongExtra(EXTRA_CACHE_GROUP_ID, -1L) ?: -1L) + ROUTE_BOOK_CACHE_MANAGE -> MainRouteBookCacheManage ROUTE_SEARCH -> MainRouteSearch( key = intent?.getStringExtra(EXTRA_SEARCH_KEY), scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE) diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index fe7bae282..95d08b1d0 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -62,6 +62,7 @@ import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.explore.ExploreScreen import io.legado.app.ui.main.my.MyScreen +import io.legado.app.ui.main.my.PrefClickEvent import io.legado.app.ui.main.rss.RssScreen import io.legado.app.ui.theme.regularHazeEffect import io.legado.app.ui.widget.components.AppNavigationBar @@ -93,6 +94,7 @@ fun MainScreen( onNavigateToRemoteImport: () -> Unit, onNavigateToLocalImport: () -> Unit, onNavigateToCache: (Long) -> Unit, + onNavigateToBookCacheManage: () -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit ) { @@ -397,7 +399,11 @@ fun MainScreen( viewModel = koinViewModel(), onOpenSettings = onOpenSettings, onNavigate = { event -> - viewModel.onPrefClickEvent(context, event) + if (event == PrefClickEvent.OpenBookCacheManage) { + onNavigateToBookCacheManage() + } else { + viewModel.onPrefClickEvent(context, event) + } } ) } diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index 080a6507e..c9eef8cee 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.filled.Rule import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.FindReplace import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.History @@ -173,6 +174,13 @@ fun MyScreen( onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java)) } ) + ClickableSettingItem( + title = "缓存管理", + imageVector = Icons.Default.Download, + onClick = { + onNavigate(PrefClickEvent.OpenBookCacheManage) + } + ) ClickableSettingItem( title = stringResource(R.string.file_manage), imageVector = Icons.Default.Folder, diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt b/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt index fe28ba081..1c695a662 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt @@ -22,6 +22,7 @@ sealed class PrefClickEvent { data class CopyUrl(val url: String) : PrefClickEvent() data class ShowMd(val title: String, val path: String) : PrefClickEvent() data class StartActivity(val destination: Class<*>, val configTag: String? = null) : PrefClickEvent() + object OpenBookCacheManage : PrefClickEvent() object ToggleWebService : PrefClickEvent() object ExitApp : PrefClickEvent() } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4ece937bc..64dae3c67 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -245,6 +245,8 @@ %1$d/%2$d 导入选择书籍 更新和搜索线程数 + 书籍缓存下载线程数 + 控制离线缓存章节下载的并发数量 切换图标 已在书架 已在书架 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 945da3122..a7bfba451 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -248,6 +248,8 @@ Downloaded %1$d/%2$d Import selected book(s) Number of Concurrent Tasks + Cache Download Threads + Controls concurrent chapter downloads for offline book cache Change Icon In library Already in Bookshelf