[优化] 新增下载线程调整和新增缓存管理界面,可以管理下载书籍和章节等
This commit is contained in:
@@ -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
|
||||
|
||||
+443
@@ -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
|
||||
)
|
||||
}
|
||||
+264
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user