feat(cache): 添加漫画图片缓存功能及图片缓存进度跟踪功能 (#1610)
* feat(cache): 添加图片缓存进度跟踪功能 - 在 BookHelp 中新增 countImagesInContent 函数用于统计章节中的图片数量 - 修改 saveImages 函数支持进度回调,实现图片下载进度追踪 - 新增 countImageCachedChapters 函数用于统计已缓存的图片章节 - 在 OkHttpStreamFetcher 中实现本地图片加载逻辑,支持从缓存读取图片 - 添加 CacheChapterProgress 相关数据类用于跟踪章节下载进度 - 在 CacheDownloadStateStore 中实现章节进度更新和清除功能 - 修改 CacheBookModel 支持内容和图片分阶段下载,并报告进度 - 更新 ReadManga 中的图片缓存逻辑,在内容下载后自动缓存图片 - 在 BookCacheManageScreen 和 ViewModel 中添加进度指示器显示下载状态 - 优化缓存章节计数逻辑,区分图片书籍和其他类型书籍的缓存判断 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cache): 修复 PR #1610 Code Review 指出的问题 - 图片缓存完成后保留正文内容,避免阅读器无限加载 - 将 ReadManga 中的磁盘 I/O 移至 IO 线程 - 使用 Mutex 保证图片下载进度回调顺序递增 * fix(cache): 修复图片缓存任务卡住与 UI 刷新性能问题 - startImageCacheTask 提前返回时清理 onDownloadSet 并同步队列状态 - 新增 hasImageFilesCached 轻量检查,进度更新时避免全量文件扫描 --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,10 +37,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.text.similarity.JaccardSimilarity
|
||||
import splitties.init.appCtx
|
||||
@@ -285,15 +288,35 @@ object BookHelp {
|
||||
}
|
||||
}
|
||||
|
||||
fun countImagesInContent(bookChapter: BookChapter, content: String): Int {
|
||||
var count = 0
|
||||
val matcher = AppPattern.imgPattern.matcher(content)
|
||||
while (matcher.find()) {
|
||||
if (matcher.group(1) != null) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
suspend fun saveImages(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
bookChapter: BookChapter,
|
||||
content: String,
|
||||
concurrency: Int = OtherConfig.threadCount
|
||||
concurrency: Int = OtherConfig.threadCount,
|
||||
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
|
||||
) = coroutineScope {
|
||||
flowImages(bookChapter, content).onEachParallel(concurrency) { mSrc ->
|
||||
val imageUrls = flowImages(bookChapter, content).toList()
|
||||
val total = imageUrls.size
|
||||
onProgress?.invoke(0, total)
|
||||
if (total == 0) return@coroutineScope
|
||||
val progressMutex = Mutex()
|
||||
var completed = 0
|
||||
imageUrls.asFlow().onEachParallel(concurrency) { mSrc ->
|
||||
saveImage(bookSource, book, mSrc, bookChapter)
|
||||
progressMutex.withLock {
|
||||
completed++
|
||||
onProgress?.invoke(completed, total)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
|
||||
@@ -464,6 +487,13 @@ object BookHelp {
|
||||
/**
|
||||
* 检测该章节是否下载
|
||||
*/
|
||||
fun countImageCachedChapters(book: Book): Int {
|
||||
if (!book.isImage) return 0
|
||||
return appDb.bookChapterDao.getChapterList(book.bookUrl).count { chapter ->
|
||||
chapter.isVolume || hasImageFilesCached(book, chapter)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
return if (book.isLocalTxt ||
|
||||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
|
||||
@@ -479,7 +509,24 @@ object BookHelp {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片是否下载
|
||||
* UI/队列用:仅检查图片文件是否都存在,不做 bitmap 解码校验。
|
||||
*/
|
||||
fun hasImageFilesCached(book: Book, bookChapter: BookChapter): Boolean {
|
||||
if (!hasContent(book, bookChapter)) {
|
||||
return false
|
||||
}
|
||||
var ret = true
|
||||
forEachImageSrc(book, bookChapter) { src ->
|
||||
if (!isImageExist(book, src)) {
|
||||
ret = false
|
||||
return@forEachImageSrc
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片是否下载(含 bitmap 解码校验,用于实际下载决策)
|
||||
*/
|
||||
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||
if (!hasContent(book, bookChapter)) {
|
||||
|
||||
@@ -9,7 +9,9 @@ import com.bumptech.glide.load.model.GlideUrl
|
||||
import com.bumptech.glide.util.ContentLengthInputStream
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import io.legado.app.data.entities.BaseSource
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.exception.NoStackTraceException
|
||||
import io.legado.app.help.book.BookHelp
|
||||
import io.legado.app.help.coroutine.Coroutine
|
||||
import io.legado.app.help.http.addHeaders
|
||||
import io.legado.app.help.http.okHttpClient
|
||||
@@ -45,6 +47,7 @@ class OkHttpStreamFetcher(
|
||||
private val coroutineContext = SupervisorJob()
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
private lateinit var analyzedUrl: GlideUrl
|
||||
private var dataSource = DataSource.REMOTE
|
||||
|
||||
@Volatile
|
||||
private var call: Call? = null
|
||||
@@ -68,6 +71,20 @@ class OkHttpStreamFetcher(
|
||||
source = SourceHelp.getSource(sourceUrl)
|
||||
}
|
||||
|
||||
this.callback = callback
|
||||
|
||||
if (manga) {
|
||||
ReadManga.book?.let { book ->
|
||||
val src = url.toString()
|
||||
if (BookHelp.isImageExist(book, src)) {
|
||||
Coroutine.async(coroutineScope, executeContext = IO) {
|
||||
loadLocalImage(book, src)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analyzedUrl = AnalyzeUrl(
|
||||
url.toString(),
|
||||
source = source,
|
||||
@@ -77,7 +94,7 @@ class OkHttpStreamFetcher(
|
||||
val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
|
||||
requestBuilder.addHeaders(analyzedUrl.headers)
|
||||
val request: Request = requestBuilder.build()
|
||||
this.callback = callback
|
||||
dataSource = DataSource.REMOTE
|
||||
call = if (manga) {
|
||||
okHttpClientManga.newCall(request)
|
||||
} else {
|
||||
@@ -86,6 +103,29 @@ class OkHttpStreamFetcher(
|
||||
call?.enqueue(this)
|
||||
}
|
||||
|
||||
private suspend fun loadLocalImage(book: Book, src: String) {
|
||||
try {
|
||||
val bytes = BookHelp.getImage(book, src).readBytes()
|
||||
val decodeResult = if (ImageUtils.skipDecode(source, isCover = false)) {
|
||||
ByteArrayInputStream(bytes)
|
||||
} else {
|
||||
runScriptWithContext(coroutineContext) {
|
||||
ImageUtils.decode(
|
||||
src,
|
||||
bytes,
|
||||
isCover = false,
|
||||
source,
|
||||
book
|
||||
)?.inputStream()
|
||||
}
|
||||
}
|
||||
dataSource = DataSource.LOCAL
|
||||
onStreamReady(decodeResult)
|
||||
} catch (e: Exception) {
|
||||
callback?.onLoadFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanup() {
|
||||
kotlin.runCatching {
|
||||
stream?.close()
|
||||
@@ -105,7 +145,7 @@ class OkHttpStreamFetcher(
|
||||
}
|
||||
|
||||
override fun getDataSource(): DataSource {
|
||||
return DataSource.REMOTE
|
||||
return dataSource
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
|
||||
@@ -4,7 +4,9 @@ import io.legado.app.constant.AppLog
|
||||
import io.legado.app.data.entities.Book
|
||||
import io.legado.app.data.entities.BookChapter
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.help.book.isImage
|
||||
import io.legado.app.exception.ConcurrentException
|
||||
import io.legado.app.help.book.BookHelp
|
||||
import io.legado.app.help.coroutine.CompositeCoroutine
|
||||
import io.legado.app.help.coroutine.Coroutine
|
||||
import io.legado.app.model.cache.CacheDownloadCandidate
|
||||
@@ -12,6 +14,8 @@ import io.legado.app.model.cache.CacheDownloadQueue
|
||||
import io.legado.app.model.cache.CacheDownloadRepository
|
||||
import io.legado.app.model.cache.CacheDownloadRequest
|
||||
import io.legado.app.model.cache.CacheDownloadSource
|
||||
import io.legado.app.model.cache.CacheChapterProgress
|
||||
import io.legado.app.model.cache.CacheChapterProgressPhase
|
||||
import io.legado.app.model.cache.CacheDownloadStateStore
|
||||
import io.legado.app.model.cache.ChapterSelection
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -423,6 +427,7 @@ class CacheBookModel(
|
||||
}
|
||||
|
||||
if (repository.hasContent(book, chapter)) {
|
||||
reportImageDownloadProgress(chapter, completed = 0)
|
||||
val task = repository.saveCachedImagesTask(
|
||||
scope = scope,
|
||||
context = context,
|
||||
@@ -430,8 +435,11 @@ class CacheBookModel(
|
||||
book = book,
|
||||
chapter = chapter,
|
||||
start = CoroutineStart.LAZY,
|
||||
onProgress = { completed, total ->
|
||||
reportImageDownloadProgress(chapter, completed, total)
|
||||
},
|
||||
)
|
||||
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
|
||||
if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context)) {
|
||||
task.cancel()
|
||||
return
|
||||
}
|
||||
@@ -439,6 +447,7 @@ class CacheBookModel(
|
||||
return
|
||||
}
|
||||
|
||||
reportContentDownloadProgress(chapterIndex)
|
||||
val task = repository.cacheContentTask(
|
||||
scope = scope,
|
||||
bookSource = bookSource,
|
||||
@@ -448,7 +457,7 @@ class CacheBookModel(
|
||||
start = CoroutineStart.LAZY,
|
||||
executeContext = context,
|
||||
)
|
||||
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
|
||||
if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context, chainImagesAfterContent = true)) {
|
||||
task.cancel()
|
||||
return
|
||||
}
|
||||
@@ -494,6 +503,9 @@ class CacheBookModel(
|
||||
task: Coroutine<T>,
|
||||
chapter: BookChapter,
|
||||
chapterIndex: Int,
|
||||
scope: CoroutineScope,
|
||||
context: CoroutineContext,
|
||||
chainImagesAfterContent: Boolean = false,
|
||||
): Boolean {
|
||||
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
|
||||
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
|
||||
@@ -503,7 +515,7 @@ class CacheBookModel(
|
||||
}
|
||||
return false
|
||||
}
|
||||
attachCallbacks(task, chapter, chapterIndex)
|
||||
attachCallbacks(task, chapter, chapterIndex, scope, context, chainImagesAfterContent)
|
||||
chapterTasks[chapterIndex] = task
|
||||
tasks.add(task)
|
||||
return true
|
||||
@@ -513,12 +525,17 @@ class CacheBookModel(
|
||||
task: Coroutine<T>,
|
||||
chapter: BookChapter,
|
||||
chapterIndex: Int,
|
||||
scope: CoroutineScope,
|
||||
context: CoroutineContext,
|
||||
chainImagesAfterContent: Boolean = false,
|
||||
content: String? = null,
|
||||
) {
|
||||
task.onSuccess(IO) {
|
||||
onSuccess(chapter)
|
||||
(it as? String)?.let { content ->
|
||||
emitPendingReadContent(chapter, content)
|
||||
if (chainImagesAfterContent && book.isImage && !repository.hasImageContent(book, chapter)) {
|
||||
startImageCacheTask(scope, context, chapter, chapterIndex, it as String)
|
||||
return@onSuccess
|
||||
}
|
||||
completeChapterCache(chapter, content ?: (it as? String))
|
||||
}.onError(IO) {
|
||||
onPreError(chapter, it)
|
||||
try {
|
||||
@@ -531,11 +548,89 @@ class CacheBookModel(
|
||||
onCancel(chapterIndex)
|
||||
emitPendingReadCanceled(chapter)
|
||||
}.onFinally(IO) {
|
||||
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
|
||||
if (chapterTasks[chapterIndex] === task) {
|
||||
chapterTasks.remove(chapterIndex)
|
||||
tasks.delete(task)
|
||||
}
|
||||
onFinally()
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeChapterCache(chapter: BookChapter, content: String?) {
|
||||
onSuccess(chapter)
|
||||
content?.let { emitPendingReadContent(chapter, it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startImageCacheTask(
|
||||
scope: CoroutineScope,
|
||||
context: CoroutineContext,
|
||||
chapter: BookChapter,
|
||||
chapterIndex: Int,
|
||||
content: String,
|
||||
) {
|
||||
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
|
||||
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
|
||||
queue.enqueue(ChapterSelection.Single(chapterIndex))
|
||||
} else {
|
||||
onDownloadSet.remove(chapterIndex)
|
||||
}
|
||||
host.stateStore.clearChapterProgress(book.bookUrl, chapterIndex)
|
||||
notifyDownloadSetChanged()
|
||||
host.onTaskQueuesChanged(book.bookUrl)
|
||||
return
|
||||
}
|
||||
reportImageDownloadProgress(chapter, completed = 0)
|
||||
val imageTask = repository.saveCachedImagesTask(
|
||||
scope = scope,
|
||||
context = context,
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
chapter = chapter,
|
||||
start = CoroutineStart.LAZY,
|
||||
onProgress = { completed, total ->
|
||||
reportImageDownloadProgress(chapter, completed, total)
|
||||
},
|
||||
)
|
||||
attachCallbacks(imageTask, chapter, chapterIndex, scope, context, content = content)
|
||||
chapterTasks[chapterIndex] = imageTask
|
||||
tasks.add(imageTask)
|
||||
imageTask.start()
|
||||
}
|
||||
|
||||
private fun reportContentDownloadProgress(chapterIndex: Int) {
|
||||
host.stateStore.updateChapterProgress(
|
||||
book.bookUrl,
|
||||
chapterIndex,
|
||||
CacheChapterProgress(
|
||||
phase = CacheChapterProgressPhase.CONTENT,
|
||||
completed = 0,
|
||||
total = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reportImageDownloadProgress(
|
||||
chapter: BookChapter,
|
||||
completed: Int,
|
||||
total: Int = imageCountInChapter(chapter),
|
||||
) {
|
||||
host.stateStore.updateChapterProgress(
|
||||
book.bookUrl,
|
||||
chapter.index,
|
||||
CacheChapterProgress(
|
||||
phase = CacheChapterProgressPhase.IMAGES,
|
||||
completed = completed,
|
||||
total = total,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun imageCountInChapter(chapter: BookChapter): Int {
|
||||
val content = BookHelp.getContent(book, chapter) ?: return 0
|
||||
return BookHelp.countImagesInContent(chapter, content)
|
||||
}
|
||||
|
||||
suspend fun downloadAwait(chapter: BookChapter): String {
|
||||
synchronized(this) {
|
||||
onDownloadSet.add(chapter.index)
|
||||
@@ -544,6 +639,16 @@ class CacheBookModel(
|
||||
}
|
||||
try {
|
||||
val content = repository.downloadContentAwait(bookSource, book, chapter)
|
||||
if (book.isImage && !repository.hasImageContent(book, chapter)) {
|
||||
repository.saveCachedImagesAwait(
|
||||
bookSource = bookSource,
|
||||
book = book,
|
||||
chapter = chapter,
|
||||
onProgress = { completed, total ->
|
||||
reportImageDownloadProgress(chapter, completed, total)
|
||||
},
|
||||
)
|
||||
}
|
||||
onSuccess(chapter)
|
||||
ReadBook.downloadedChapters.add(chapter.index)
|
||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||
@@ -599,6 +704,11 @@ class CacheBookModel(
|
||||
executeContext = IO,
|
||||
semaphore = semaphore
|
||||
).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content ->
|
||||
if (book.isImage && !repository.hasImageContent(book, chapter)) {
|
||||
Coroutine.async(scope, IO) {
|
||||
repository.saveCachedImagesAwait(bookSource, book, chapter)
|
||||
}.start()
|
||||
}
|
||||
onSuccess(chapter)
|
||||
ReadBook.downloadedChapters.add(chapter.index)
|
||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||
|
||||
@@ -13,6 +13,7 @@ import io.legado.app.help.AppWebDav
|
||||
import io.legado.app.help.ConcurrentRateLimiter
|
||||
import io.legado.app.help.book.BookHelp
|
||||
import io.legado.app.help.book.ContentProcessor
|
||||
import io.legado.app.help.book.isImage
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.book.isSameNameAuthor
|
||||
import io.legado.app.help.book.readSimulating
|
||||
@@ -468,6 +469,20 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun cacheChapterImagesIfNeeded(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
content: String? = null,
|
||||
) {
|
||||
if (!book.isImage) return
|
||||
val source = bookSource ?: return
|
||||
Coroutine.async(downloadScope, IO) {
|
||||
if (BookHelp.hasImageContent(book, chapter)) return@async
|
||||
val resolvedContent = content ?: BookHelp.getContent(book, chapter) ?: return@async
|
||||
BookHelp.saveImages(source, book, chapter, resolvedContent)
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun preDownload() {
|
||||
if (book?.isLocal == true) return
|
||||
executor.execute {
|
||||
@@ -515,6 +530,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
|
||||
if (BookHelp.hasContent(book, chapter)) {
|
||||
downloadedChapters.add(chapter.index)
|
||||
cacheChapterImagesIfNeeded(book, chapter)
|
||||
} else {
|
||||
delay(1000)
|
||||
if (addLoading(index)) {
|
||||
@@ -534,10 +550,11 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
||||
val book = book ?: return removeLoading(chapter.index)
|
||||
val bookSource = bookSource
|
||||
if (bookSource != null) {
|
||||
downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = {
|
||||
downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { content ->
|
||||
cacheChapterImagesIfNeeded(book, chapter, content)
|
||||
downloadedChapters.add(chapter.index)
|
||||
downloadFailChapters.remove(chapter.index)
|
||||
contentLoadFinish(chapter, it)
|
||||
contentLoadFinish(chapter, content)
|
||||
}, error = {
|
||||
downloadFailChapters[chapter.index] =
|
||||
(downloadFailChapters[chapter.index] ?: 0) + 1
|
||||
|
||||
@@ -45,4 +45,19 @@ data class CacheBookDownloadState(
|
||||
val failedIndices: Set<Int> = emptySet(),
|
||||
val successCount: Int = 0,
|
||||
val failureMessage: String? = null,
|
||||
val chapterProgress: Map<Int, CacheChapterProgress> = emptyMap(),
|
||||
)
|
||||
|
||||
enum class CacheChapterProgressPhase {
|
||||
CONTENT,
|
||||
IMAGES,
|
||||
}
|
||||
|
||||
data class CacheChapterProgress(
|
||||
val phase: CacheChapterProgressPhase,
|
||||
val completed: Int,
|
||||
val total: Int,
|
||||
) {
|
||||
val fraction: Float
|
||||
get() = if (total <= 0) 0f else completed.toFloat() / total
|
||||
}
|
||||
|
||||
@@ -33,11 +33,21 @@ class CacheDownloadRepository {
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
start: CoroutineStart = CoroutineStart.LAZY,
|
||||
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
|
||||
): Coroutine<Unit> {
|
||||
return Coroutine.async(scope, context, start = start, executeContext = context) {
|
||||
BookHelp.getContent(book, chapter)?.let {
|
||||
BookHelp.saveImages(bookSource, book, chapter, it, 1)
|
||||
}
|
||||
saveCachedImagesAwait(bookSource, book, chapter, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCachedImagesAwait(
|
||||
bookSource: BookSource,
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
|
||||
) {
|
||||
BookHelp.getContent(book, chapter)?.let { content ->
|
||||
BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class CacheDownloadStateStore {
|
||||
failedIndices = current.failedIndices - chapterIndex,
|
||||
successCount = current.successCount + 1,
|
||||
failureMessage = null,
|
||||
chapterProgress = current.chapterProgress - chapterIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -50,10 +51,27 @@ class CacheDownloadStateStore {
|
||||
runningIndices = current.runningIndices - chapterIndex,
|
||||
pausedIndices = current.pausedIndices - chapterIndex,
|
||||
failedIndices = current.failedIndices + chapterIndex,
|
||||
chapterProgress = current.chapterProgress - chapterIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChapterProgress(
|
||||
bookUrl: String,
|
||||
chapterIndex: Int,
|
||||
progress: CacheChapterProgress,
|
||||
) {
|
||||
updateBook(bookUrl) { current ->
|
||||
current.copy(chapterProgress = current.chapterProgress + (chapterIndex to progress))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearChapterProgress(bookUrl: String, chapterIndex: Int) {
|
||||
updateBook(bookUrl) { current ->
|
||||
current.copy(chapterProgress = current.chapterProgress - chapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun markBookFailed(bookUrl: String, message: String) {
|
||||
updateBook(bookUrl) { current ->
|
||||
current.copy(
|
||||
@@ -90,6 +108,7 @@ class CacheDownloadStateStore {
|
||||
waitingCount = 0,
|
||||
runningIndices = emptySet(),
|
||||
successCount = 0,
|
||||
chapterProgress = emptyMap(),
|
||||
)
|
||||
}
|
||||
.filterValues { bookState ->
|
||||
|
||||
+11
@@ -410,6 +410,14 @@ private fun BookCacheChapterRow(
|
||||
LegadoTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
if (item.isDownloading) {
|
||||
AppLinearProgressIndicator(
|
||||
progress = item.downloadProgress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (item.isWaiting || item.isDownloading) {
|
||||
SmallTonalButton(
|
||||
@@ -433,6 +441,9 @@ private fun BookCacheChapterRow(
|
||||
}
|
||||
|
||||
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
||||
if (item.isDownloading && !item.progressLabel.isNullOrBlank()) {
|
||||
return item.progressLabel
|
||||
}
|
||||
return when {
|
||||
item.isDownloading -> "下载中"
|
||||
item.isWaiting -> "等待下载"
|
||||
|
||||
+173
-6
@@ -12,9 +12,14 @@ 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.isImage
|
||||
import io.legado.app.help.book.isLocal
|
||||
import io.legado.app.help.book.isNotShelf
|
||||
import io.legado.app.model.CacheBook
|
||||
import io.legado.app.model.CacheBookModel
|
||||
import io.legado.app.model.cache.CacheBookDownloadState
|
||||
import io.legado.app.model.cache.CacheChapterProgress
|
||||
import io.legado.app.model.cache.CacheChapterProgressPhase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
@@ -71,6 +76,8 @@ data class BookCacheChapterItem(
|
||||
val isDownloading: Boolean,
|
||||
val isPaused: Boolean,
|
||||
val isError: Boolean,
|
||||
val downloadProgress: Float? = null,
|
||||
val progressLabel: String? = null,
|
||||
)
|
||||
|
||||
sealed interface BookCacheManageIntent {
|
||||
@@ -163,7 +170,13 @@ class BookCacheManageViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
CacheBook.downloadStateFlow.collect { state ->
|
||||
state.books.keys.forEach { scheduleDownloadStatusRefresh(it) }
|
||||
state.books.forEach { (bookUrl, bookState) ->
|
||||
if (bookState.runningIndices.isNotEmpty() || bookState.chapterProgress.isNotEmpty()) {
|
||||
scheduleProgressUpdate(bookUrl, debounceMillis = 100)
|
||||
} else {
|
||||
scheduleDownloadStatusRefresh(bookUrl)
|
||||
}
|
||||
}
|
||||
pendingDownloadSummaryRefresh = true
|
||||
}
|
||||
}
|
||||
@@ -257,6 +270,20 @@ class BookCacheManageViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleProgressUpdate(bookUrl: String, debounceMillis: Long = 100) {
|
||||
if (bookUrl.isBlank()) return
|
||||
bookReloadJobs.remove(bookUrl)?.cancel()
|
||||
bookReloadJobs[bookUrl] = viewModelScope.launch {
|
||||
if (debounceMillis > 0) {
|
||||
delay(debounceMillis)
|
||||
}
|
||||
updateDownloadProgressOnly(bookUrl)
|
||||
if (bookReloadJobs[bookUrl] == currentCoroutineContext()[Job]) {
|
||||
bookReloadJobs.remove(bookUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
|
||||
if (bookUrl.isBlank()) return
|
||||
bookReloadJobs.remove(bookUrl)?.cancel()
|
||||
@@ -334,7 +361,11 @@ class BookCacheManageViewModel(
|
||||
val errorIndices = errorIndices(book.bookUrl)
|
||||
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
|
||||
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
|
||||
val cachedCount = min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
|
||||
val cachedCount = if (book.isImage) {
|
||||
min(BookHelp.countImageCachedChapters(book), totalCount)
|
||||
} else {
|
||||
min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount)
|
||||
}
|
||||
if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) {
|
||||
return null
|
||||
}
|
||||
@@ -362,22 +393,71 @@ class BookCacheManageViewModel(
|
||||
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
||||
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||
val model = CacheBook.cacheBookMap[bookUrl]
|
||||
val bookState = CacheBook.downloadStateFlow.value.books[bookUrl]
|
||||
val errorIndices = errorIndices(bookUrl)
|
||||
return chapters.map { chapter ->
|
||||
val isPaused = model?.isPaused(chapter.index) == true
|
||||
val isWaiting = !isPaused && model?.isWaiting(chapter.index) == true
|
||||
val isDownloading = !isPaused && model?.isDownloading(chapter.index) == true
|
||||
val bookChapter = BookChapter(
|
||||
bookUrl = book.bookUrl,
|
||||
url = chapter.url,
|
||||
title = chapter.title,
|
||||
isVolume = chapter.isVolume,
|
||||
index = chapter.index,
|
||||
)
|
||||
val isCached = if (book.isImage) {
|
||||
when {
|
||||
chapter.isVolume -> true
|
||||
isDownloading -> false
|
||||
else -> BookHelp.hasImageFilesCached(book, bookChapter)
|
||||
}
|
||||
} else {
|
||||
cacheFiles.contains(chapter.getFileName()) || chapter.isVolume
|
||||
}
|
||||
val chapterProgress = bookState?.chapterProgress?.get(chapter.index)
|
||||
val (downloadProgress, progressLabel) = chapterProgressUi(
|
||||
isDownloading = isDownloading,
|
||||
progress = chapterProgress,
|
||||
)
|
||||
BookCacheChapterItem(
|
||||
chapterUrl = chapter.url,
|
||||
title = chapter.title,
|
||||
index = chapter.index,
|
||||
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
|
||||
isWaiting = !isPaused && model?.isWaiting(chapter.index) == true,
|
||||
isDownloading = !isPaused && model?.isDownloading(chapter.index) == true,
|
||||
isCached = isCached,
|
||||
isWaiting = isWaiting,
|
||||
isDownloading = isDownloading,
|
||||
isPaused = isPaused,
|
||||
isError = errorIndices.contains(chapter.index),
|
||||
downloadProgress = downloadProgress,
|
||||
progressLabel = progressLabel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun chapterProgressUi(
|
||||
isDownloading: Boolean,
|
||||
progress: CacheChapterProgress?,
|
||||
): Pair<Float?, String?> {
|
||||
if (!isDownloading || progress == null) return null to null
|
||||
return when (progress.phase) {
|
||||
CacheChapterProgressPhase.CONTENT -> {
|
||||
if (progress.completed >= progress.total && progress.total > 0) {
|
||||
progress.fraction to "正文已下载"
|
||||
} else {
|
||||
null to "下载正文"
|
||||
}
|
||||
}
|
||||
CacheChapterProgressPhase.IMAGES -> {
|
||||
if (progress.total <= 0) {
|
||||
null to "下载图片"
|
||||
} else {
|
||||
progress.fraction to "图片 ${progress.completed}/${progress.total}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun errorIndices(bookUrl: String): Set<Int> {
|
||||
return CacheBook.errorIndices(bookUrl)
|
||||
}
|
||||
@@ -502,7 +582,7 @@ class BookCacheManageViewModel(
|
||||
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
|
||||
if (
|
||||
chapter.isVolume ||
|
||||
cacheFiles.contains(chapter.getFileName()) ||
|
||||
isChapterFullyCached(book, chapter, cacheFiles) ||
|
||||
model?.isPaused(chapter.index) == true ||
|
||||
model?.isWaiting(chapter.index) == true ||
|
||||
model?.isDownloading(chapter.index) == true
|
||||
@@ -520,6 +600,93 @@ class BookCacheManageViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isChapterFullyCached(
|
||||
book: Book,
|
||||
chapter: BookChapterCacheInfo,
|
||||
cacheFiles: Set<String>,
|
||||
): Boolean {
|
||||
if (chapter.isVolume) return true
|
||||
val bookChapter = BookChapter(
|
||||
bookUrl = book.bookUrl,
|
||||
url = chapter.url,
|
||||
title = chapter.title,
|
||||
isVolume = chapter.isVolume,
|
||||
index = chapter.index,
|
||||
)
|
||||
return if (book.isImage) {
|
||||
BookHelp.hasImageFilesCached(book, bookChapter)
|
||||
} else {
|
||||
cacheFiles.contains(chapter.getFileName())
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadProgressOnly(bookUrl: String) {
|
||||
val bookState = CacheBook.downloadStateFlow.value.books[bookUrl] ?: return
|
||||
val model = CacheBook.cacheBookMap[bookUrl]
|
||||
_uiState.update { state ->
|
||||
fun updateBookItem(item: BookCacheBookItem): BookCacheBookItem {
|
||||
if (item.bookUrl != bookUrl) return item
|
||||
return applyDownloadStateToBookItem(item, bookState, model)
|
||||
}
|
||||
val updatedChapters = state.chaptersByBookUrl[bookUrl]?.map { item ->
|
||||
val isPaused = model?.isPaused(item.index) == true
|
||||
val isWaiting = !isPaused && model?.isWaiting(item.index) == true
|
||||
val isDownloading = !isPaused && model?.isDownloading(item.index) == true
|
||||
val chapterProgress = bookState.chapterProgress[item.index]
|
||||
val (downloadProgress, progressLabel) = chapterProgressUi(
|
||||
isDownloading = isDownloading,
|
||||
progress = chapterProgress,
|
||||
)
|
||||
item.copy(
|
||||
isWaiting = isWaiting,
|
||||
isDownloading = isDownloading,
|
||||
isPaused = isPaused,
|
||||
downloadProgress = downloadProgress,
|
||||
progressLabel = progressLabel,
|
||||
)
|
||||
}
|
||||
val shelfBooks = state.shelfBooks.map(::updateBookItem)
|
||||
val notShelfBooks = state.notShelfBooks.map(::updateBookItem)
|
||||
state.copy(
|
||||
shelfBooks = shelfBooks,
|
||||
notShelfBooks = notShelfBooks,
|
||||
chaptersByBookUrl = if (updatedChapters != null) {
|
||||
state.chaptersByBookUrl + (bookUrl to updatedChapters)
|
||||
} else {
|
||||
state.chaptersByBookUrl
|
||||
},
|
||||
downloadSummary = buildDownloadSummary(shelfBooks + notShelfBooks),
|
||||
hasPausedDownloads = CacheBook.hasPausedDownloads,
|
||||
version = state.version + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDownloadStateToBookItem(
|
||||
item: BookCacheBookItem,
|
||||
bookState: CacheBookDownloadState,
|
||||
model: CacheBookModel?,
|
||||
): BookCacheBookItem {
|
||||
val rawWaitingCount = bookState.waitingCount +
|
||||
CacheBook.pendingAdmissionFlow.value[item.bookUrl].orZero()
|
||||
val rawDownloadingCount = bookState.runningIndices.size
|
||||
val isBookPaused = model?.isPaused() == true ||
|
||||
(CacheBook.hasPausedDownloads && CacheBook.pendingAdmissionFlow.value.containsKey(item.bookUrl))
|
||||
val pausedCount = if (isBookPaused) {
|
||||
rawWaitingCount + rawDownloadingCount + bookState.pausedIndices.size
|
||||
} else {
|
||||
bookState.pausedIndices.size
|
||||
}
|
||||
val waitingCount = if (isBookPaused) 0 else rawWaitingCount
|
||||
val downloadingCount = if (isBookPaused) 0 else rawDownloadingCount
|
||||
return item.copy(
|
||||
waitingCount = waitingCount,
|
||||
downloadingCount = downloadingCount,
|
||||
pausedCount = pausedCount,
|
||||
errorCount = errorIndices(item.bookUrl).size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteBookCache(bookUrl: String) {
|
||||
execute {
|
||||
CacheBook.removeAwait(context, bookUrl)
|
||||
|
||||
@@ -83,4 +83,25 @@ class CacheDownloadStateStoreTest {
|
||||
assertEquals(0, store.state.totalWaiting)
|
||||
assertEquals(0, store.state.totalRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chapterProgressUpdatesAndClearsOnSuccess() {
|
||||
val store = CacheDownloadStateStore()
|
||||
|
||||
store.updateBookQueue("a", waitingCount = 0, runningIndices = setOf(3))
|
||||
store.updateChapterProgress(
|
||||
"a",
|
||||
3,
|
||||
CacheChapterProgress(CacheChapterProgressPhase.IMAGES, completed = 2, total = 10),
|
||||
)
|
||||
|
||||
val progress = store.state.books.getValue("a").chapterProgress.getValue(3)
|
||||
assertEquals(CacheChapterProgressPhase.IMAGES, progress.phase)
|
||||
assertEquals(2, progress.completed)
|
||||
assertEquals(10, progress.total)
|
||||
|
||||
store.markSuccess("a", 3)
|
||||
|
||||
assertFalse(store.state.books.getValue("a").chapterProgress.containsKey(3))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user