Просмотр исходного кода

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

HapeLee 6 месяцев назад
Родитель
Сommit
97676113ef

+ 1 - 0
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"

+ 2 - 0
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(),

+ 8 - 2
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<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

+ 1 - 1
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

+ 443 - 0
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<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
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<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()
+        }
+    }
+}

+ 5 - 0
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

+ 9 - 0
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),

+ 23 - 1
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<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)

+ 7 - 1
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)
+                                }
                             }
                         )
                     }

+ 8 - 0
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,

+ 1 - 0
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()
 }

+ 2 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -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 - 0
app/src/main/res/values/strings.xml

@@ -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>