[优化] 新增下载线程调整和新增缓存管理界面,可以管理下载书籍和章节等

This commit is contained in:
HapeLee
2026-04-27 00:57:20 +08:00
parent 0fb59eaa98
commit 97676113ef
14 changed files with 776 additions and 5 deletions
@@ -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"
@@ -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(),
@@ -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<Int, Int> = waitDownloadSet.size to onDownloadSet.size
@Synchronized
fun waitingIndices(): Set<Int> = waitDownloadSet.toSet()
@Synchronized
fun downloadingIndices(): Set<Int> = onDownloadSet.toSet()
@Synchronized
fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
@@ -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
@@ -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<BookCacheBookItem?>(null) }
var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
var expandedBookUrls by rememberSaveable { mutableStateOf(emptySet<String>()) }
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<BookCacheBookItem>,
expandedBookUrls: Set<String>,
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<String>.toggle(value: String): Set<String> {
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<BookCacheBookItem, BookCacheChapterItem>?,
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
)
}
@@ -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<BookCacheBookItem> = emptyList(),
val notShelfBooks: List<BookCacheBookItem> = emptyList(),
val downloadSummary: String = CacheBook.downloadSummary,
val version: Long = 0,
)
data class BookCacheBookItem(
val book: Book,
val chapters: List<BookCacheChapterItem>,
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<BookCacheManageEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
private var observeJob: Job? = null
private val chapterCache = hashMapOf<String, List<BookChapter>>()
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<Book>? = 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<BookCacheBookItem> { 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()
}
}
}
@@ -160,6 +160,11 @@ object OtherConfig {
16
)
var cacheBookThreadCount by prefDelegate(
PreferKey.cacheBookThreadCount,
16
)
var processText by prefDelegate(
PreferKey.processText,
true
@@ -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),
@@ -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<MainRouteBookCacheManage> {
BookCacheManageRouteScreen(
onBackClick = { navigateBack(backStack) }
)
}
entry<MainRouteSearch> { route ->
val searchViewModel = koinViewModel<SearchViewModel>()
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)
@@ -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)
}
}
)
}
@@ -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,
@@ -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()
}
@@ -245,6 +245,8 @@
<string name="download_count">%1$d/%2$d</string>
<string name="import_select_book">导入选择书籍</string>
<string name="threads_num_title">更新和搜索线程数</string>
<string name="cache_book_threads_num_title">书籍缓存下载线程数</string>
<string name="cache_book_threads_num_summary">控制离线缓存章节下载的并发数量</string>
<string name="change_icon">切换图标</string>
<string name="remove_from_bookshelf">已在书架</string>
<string name="already_in_bookshelf">已在书架</string>
+2
View File
@@ -248,6 +248,8 @@
<string name="download_count">Downloaded %1$d/%2$d</string>
<string name="import_select_book">Import selected book(s)</string>
<string name="threads_num_title">Number of Concurrent Tasks</string>
<string name="cache_book_threads_num_title">Cache Download Threads</string>
<string name="cache_book_threads_num_summary">Controls concurrent chapter downloads for offline book cache</string>
<string name="change_icon">Change Icon</string>
<string name="remove_from_bookshelf">In library</string>
<string name="already_in_bookshelf">Already in Bookshelf</string>