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.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.apache.commons.text.similarity.JaccardSimilarity
|
import org.apache.commons.text.similarity.JaccardSimilarity
|
||||||
import splitties.init.appCtx
|
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(
|
suspend fun saveImages(
|
||||||
bookSource: BookSource,
|
bookSource: BookSource,
|
||||||
book: Book,
|
book: Book,
|
||||||
bookChapter: BookChapter,
|
bookChapter: BookChapter,
|
||||||
content: String,
|
content: String,
|
||||||
concurrency: Int = OtherConfig.threadCount
|
concurrency: Int = OtherConfig.threadCount,
|
||||||
|
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
|
||||||
) = coroutineScope {
|
) = 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)
|
saveImage(bookSource, book, mSrc, bookChapter)
|
||||||
|
progressMutex.withLock {
|
||||||
|
completed++
|
||||||
|
onProgress?.invoke(completed, total)
|
||||||
|
}
|
||||||
}.collect()
|
}.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 {
|
fun hasContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||||
return if (book.isLocalTxt ||
|
return if (book.isLocalTxt ||
|
||||||
(bookChapter.isVolume && bookChapter.url.startsWith(bookChapter.title))
|
(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 {
|
fun hasImageContent(book: Book, bookChapter: BookChapter): Boolean {
|
||||||
if (!hasContent(book, bookChapter)) {
|
if (!hasContent(book, bookChapter)) {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import com.bumptech.glide.load.model.GlideUrl
|
|||||||
import com.bumptech.glide.util.ContentLengthInputStream
|
import com.bumptech.glide.util.ContentLengthInputStream
|
||||||
import com.script.rhino.runScriptWithContext
|
import com.script.rhino.runScriptWithContext
|
||||||
import io.legado.app.data.entities.BaseSource
|
import io.legado.app.data.entities.BaseSource
|
||||||
|
import io.legado.app.data.entities.Book
|
||||||
import io.legado.app.exception.NoStackTraceException
|
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.coroutine.Coroutine
|
||||||
import io.legado.app.help.http.addHeaders
|
import io.legado.app.help.http.addHeaders
|
||||||
import io.legado.app.help.http.okHttpClient
|
import io.legado.app.help.http.okHttpClient
|
||||||
@@ -45,6 +47,7 @@ class OkHttpStreamFetcher(
|
|||||||
private val coroutineContext = SupervisorJob()
|
private val coroutineContext = SupervisorJob()
|
||||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||||
private lateinit var analyzedUrl: GlideUrl
|
private lateinit var analyzedUrl: GlideUrl
|
||||||
|
private var dataSource = DataSource.REMOTE
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var call: Call? = null
|
private var call: Call? = null
|
||||||
@@ -68,6 +71,20 @@ class OkHttpStreamFetcher(
|
|||||||
source = SourceHelp.getSource(sourceUrl)
|
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(
|
analyzedUrl = AnalyzeUrl(
|
||||||
url.toString(),
|
url.toString(),
|
||||||
source = source,
|
source = source,
|
||||||
@@ -77,7 +94,7 @@ class OkHttpStreamFetcher(
|
|||||||
val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
|
val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
|
||||||
requestBuilder.addHeaders(analyzedUrl.headers)
|
requestBuilder.addHeaders(analyzedUrl.headers)
|
||||||
val request: Request = requestBuilder.build()
|
val request: Request = requestBuilder.build()
|
||||||
this.callback = callback
|
dataSource = DataSource.REMOTE
|
||||||
call = if (manga) {
|
call = if (manga) {
|
||||||
okHttpClientManga.newCall(request)
|
okHttpClientManga.newCall(request)
|
||||||
} else {
|
} else {
|
||||||
@@ -86,6 +103,29 @@ class OkHttpStreamFetcher(
|
|||||||
call?.enqueue(this)
|
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() {
|
override fun cleanup() {
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
stream?.close()
|
stream?.close()
|
||||||
@@ -105,7 +145,7 @@ class OkHttpStreamFetcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDataSource(): DataSource {
|
override fun getDataSource(): DataSource {
|
||||||
return DataSource.REMOTE
|
return dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
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.Book
|
||||||
import io.legado.app.data.entities.BookChapter
|
import io.legado.app.data.entities.BookChapter
|
||||||
import io.legado.app.data.entities.BookSource
|
import io.legado.app.data.entities.BookSource
|
||||||
|
import io.legado.app.help.book.isImage
|
||||||
import io.legado.app.exception.ConcurrentException
|
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.CompositeCoroutine
|
||||||
import io.legado.app.help.coroutine.Coroutine
|
import io.legado.app.help.coroutine.Coroutine
|
||||||
import io.legado.app.model.cache.CacheDownloadCandidate
|
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.CacheDownloadRepository
|
||||||
import io.legado.app.model.cache.CacheDownloadRequest
|
import io.legado.app.model.cache.CacheDownloadRequest
|
||||||
import io.legado.app.model.cache.CacheDownloadSource
|
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.CacheDownloadStateStore
|
||||||
import io.legado.app.model.cache.ChapterSelection
|
import io.legado.app.model.cache.ChapterSelection
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -423,6 +427,7 @@ class CacheBookModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (repository.hasContent(book, chapter)) {
|
if (repository.hasContent(book, chapter)) {
|
||||||
|
reportImageDownloadProgress(chapter, completed = 0)
|
||||||
val task = repository.saveCachedImagesTask(
|
val task = repository.saveCachedImagesTask(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
context = context,
|
context = context,
|
||||||
@@ -430,8 +435,11 @@ class CacheBookModel(
|
|||||||
book = book,
|
book = book,
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
start = CoroutineStart.LAZY,
|
start = CoroutineStart.LAZY,
|
||||||
|
onProgress = { completed, total ->
|
||||||
|
reportImageDownloadProgress(chapter, completed, total)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
|
if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context)) {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -439,6 +447,7 @@ class CacheBookModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportContentDownloadProgress(chapterIndex)
|
||||||
val task = repository.cacheContentTask(
|
val task = repository.cacheContentTask(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
bookSource = bookSource,
|
bookSource = bookSource,
|
||||||
@@ -448,7 +457,7 @@ class CacheBookModel(
|
|||||||
start = CoroutineStart.LAZY,
|
start = CoroutineStart.LAZY,
|
||||||
executeContext = context,
|
executeContext = context,
|
||||||
)
|
)
|
||||||
if (!attachTaskIfActive(task, chapter, chapterIndex)) {
|
if (!attachTaskIfActive(task, chapter, chapterIndex, scope, context, chainImagesAfterContent = true)) {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -494,6 +503,9 @@ class CacheBookModel(
|
|||||||
task: Coroutine<T>,
|
task: Coroutine<T>,
|
||||||
chapter: BookChapter,
|
chapter: BookChapter,
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
context: CoroutineContext,
|
||||||
|
chainImagesAfterContent: Boolean = false,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
|
if (isStopped || isPaused || !onDownloadSet.contains(chapterIndex)) {
|
||||||
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
|
if (!isStopped && isPaused && onDownloadSet.remove(chapterIndex)) {
|
||||||
@@ -503,7 +515,7 @@ class CacheBookModel(
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
attachCallbacks(task, chapter, chapterIndex)
|
attachCallbacks(task, chapter, chapterIndex, scope, context, chainImagesAfterContent)
|
||||||
chapterTasks[chapterIndex] = task
|
chapterTasks[chapterIndex] = task
|
||||||
tasks.add(task)
|
tasks.add(task)
|
||||||
return true
|
return true
|
||||||
@@ -513,12 +525,17 @@ class CacheBookModel(
|
|||||||
task: Coroutine<T>,
|
task: Coroutine<T>,
|
||||||
chapter: BookChapter,
|
chapter: BookChapter,
|
||||||
chapterIndex: Int,
|
chapterIndex: Int,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
context: CoroutineContext,
|
||||||
|
chainImagesAfterContent: Boolean = false,
|
||||||
|
content: String? = null,
|
||||||
) {
|
) {
|
||||||
task.onSuccess(IO) {
|
task.onSuccess(IO) {
|
||||||
onSuccess(chapter)
|
if (chainImagesAfterContent && book.isImage && !repository.hasImageContent(book, chapter)) {
|
||||||
(it as? String)?.let { content ->
|
startImageCacheTask(scope, context, chapter, chapterIndex, it as String)
|
||||||
emitPendingReadContent(chapter, content)
|
return@onSuccess
|
||||||
}
|
}
|
||||||
|
completeChapterCache(chapter, content ?: (it as? String))
|
||||||
}.onError(IO) {
|
}.onError(IO) {
|
||||||
onPreError(chapter, it)
|
onPreError(chapter, it)
|
||||||
try {
|
try {
|
||||||
@@ -531,11 +548,89 @@ class CacheBookModel(
|
|||||||
onCancel(chapterIndex)
|
onCancel(chapterIndex)
|
||||||
emitPendingReadCanceled(chapter)
|
emitPendingReadCanceled(chapter)
|
||||||
}.onFinally(IO) {
|
}.onFinally(IO) {
|
||||||
chapterTasks.remove(chapterIndex)?.let { tasks.delete(it) }
|
if (chapterTasks[chapterIndex] === task) {
|
||||||
|
chapterTasks.remove(chapterIndex)
|
||||||
|
tasks.delete(task)
|
||||||
|
}
|
||||||
onFinally()
|
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 {
|
suspend fun downloadAwait(chapter: BookChapter): String {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
onDownloadSet.add(chapter.index)
|
onDownloadSet.add(chapter.index)
|
||||||
@@ -544,6 +639,16 @@ class CacheBookModel(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
val content = repository.downloadContentAwait(bookSource, book, chapter)
|
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)
|
onSuccess(chapter)
|
||||||
ReadBook.downloadedChapters.add(chapter.index)
|
ReadBook.downloadedChapters.add(chapter.index)
|
||||||
ReadBook.downloadFailChapters.remove(chapter.index)
|
ReadBook.downloadFailChapters.remove(chapter.index)
|
||||||
@@ -599,6 +704,11 @@ class CacheBookModel(
|
|||||||
executeContext = IO,
|
executeContext = IO,
|
||||||
semaphore = semaphore
|
semaphore = semaphore
|
||||||
).timeout(DOWNLOAD_TIMEOUT_MS).onSuccess { content ->
|
).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)
|
onSuccess(chapter)
|
||||||
ReadBook.downloadedChapters.add(chapter.index)
|
ReadBook.downloadedChapters.add(chapter.index)
|
||||||
ReadBook.downloadFailChapters.remove(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.ConcurrentRateLimiter
|
||||||
import io.legado.app.help.book.BookHelp
|
import io.legado.app.help.book.BookHelp
|
||||||
import io.legado.app.help.book.ContentProcessor
|
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.isLocal
|
||||||
import io.legado.app.help.book.isSameNameAuthor
|
import io.legado.app.help.book.isSameNameAuthor
|
||||||
import io.legado.app.help.book.readSimulating
|
import io.legado.app.help.book.readSimulating
|
||||||
@@ -468,6 +469,20 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
|||||||
}.start()
|
}.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() {
|
private fun preDownload() {
|
||||||
if (book?.isLocal == true) return
|
if (book?.isLocal == true) return
|
||||||
executor.execute {
|
executor.execute {
|
||||||
@@ -515,6 +530,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
|||||||
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
|
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
|
||||||
if (BookHelp.hasContent(book, chapter)) {
|
if (BookHelp.hasContent(book, chapter)) {
|
||||||
downloadedChapters.add(chapter.index)
|
downloadedChapters.add(chapter.index)
|
||||||
|
cacheChapterImagesIfNeeded(book, chapter)
|
||||||
} else {
|
} else {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
if (addLoading(index)) {
|
if (addLoading(index)) {
|
||||||
@@ -534,10 +550,11 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
|
|||||||
val book = book ?: return removeLoading(chapter.index)
|
val book = book ?: return removeLoading(chapter.index)
|
||||||
val bookSource = bookSource
|
val bookSource = bookSource
|
||||||
if (bookSource != null) {
|
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)
|
downloadedChapters.add(chapter.index)
|
||||||
downloadFailChapters.remove(chapter.index)
|
downloadFailChapters.remove(chapter.index)
|
||||||
contentLoadFinish(chapter, it)
|
contentLoadFinish(chapter, content)
|
||||||
}, error = {
|
}, error = {
|
||||||
downloadFailChapters[chapter.index] =
|
downloadFailChapters[chapter.index] =
|
||||||
(downloadFailChapters[chapter.index] ?: 0) + 1
|
(downloadFailChapters[chapter.index] ?: 0) + 1
|
||||||
|
|||||||
@@ -45,4 +45,19 @@ data class CacheBookDownloadState(
|
|||||||
val failedIndices: Set<Int> = emptySet(),
|
val failedIndices: Set<Int> = emptySet(),
|
||||||
val successCount: Int = 0,
|
val successCount: Int = 0,
|
||||||
val failureMessage: String? = null,
|
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,
|
book: Book,
|
||||||
chapter: BookChapter,
|
chapter: BookChapter,
|
||||||
start: CoroutineStart = CoroutineStart.LAZY,
|
start: CoroutineStart = CoroutineStart.LAZY,
|
||||||
|
onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
|
||||||
): Coroutine<Unit> {
|
): Coroutine<Unit> {
|
||||||
return Coroutine.async(scope, context, start = start, executeContext = context) {
|
return Coroutine.async(scope, context, start = start, executeContext = context) {
|
||||||
BookHelp.getContent(book, chapter)?.let {
|
saveCachedImagesAwait(bookSource, book, chapter, onProgress)
|
||||||
BookHelp.saveImages(bookSource, book, chapter, it, 1)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
failedIndices = current.failedIndices - chapterIndex,
|
||||||
successCount = current.successCount + 1,
|
successCount = current.successCount + 1,
|
||||||
failureMessage = null,
|
failureMessage = null,
|
||||||
|
chapterProgress = current.chapterProgress - chapterIndex,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,10 +51,27 @@ class CacheDownloadStateStore {
|
|||||||
runningIndices = current.runningIndices - chapterIndex,
|
runningIndices = current.runningIndices - chapterIndex,
|
||||||
pausedIndices = current.pausedIndices - chapterIndex,
|
pausedIndices = current.pausedIndices - chapterIndex,
|
||||||
failedIndices = current.failedIndices + 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) {
|
fun markBookFailed(bookUrl: String, message: String) {
|
||||||
updateBook(bookUrl) { current ->
|
updateBook(bookUrl) { current ->
|
||||||
current.copy(
|
current.copy(
|
||||||
@@ -90,6 +108,7 @@ class CacheDownloadStateStore {
|
|||||||
waitingCount = 0,
|
waitingCount = 0,
|
||||||
runningIndices = emptySet(),
|
runningIndices = emptySet(),
|
||||||
successCount = 0,
|
successCount = 0,
|
||||||
|
chapterProgress = emptyMap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.filterValues { bookState ->
|
.filterValues { bookState ->
|
||||||
|
|||||||
+11
@@ -410,6 +410,14 @@ private fun BookCacheChapterRow(
|
|||||||
LegadoTheme.colorScheme.onSurfaceVariant
|
LegadoTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (item.isDownloading) {
|
||||||
|
AppLinearProgressIndicator(
|
||||||
|
progress = item.downloadProgress,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (item.isWaiting || item.isDownloading) {
|
if (item.isWaiting || item.isDownloading) {
|
||||||
SmallTonalButton(
|
SmallTonalButton(
|
||||||
@@ -433,6 +441,9 @@ private fun BookCacheChapterRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
private fun chapterStatusText(item: BookCacheChapterItem): String {
|
||||||
|
if (item.isDownloading && !item.progressLabel.isNullOrBlank()) {
|
||||||
|
return item.progressLabel
|
||||||
|
}
|
||||||
return when {
|
return when {
|
||||||
item.isDownloading -> "下载中"
|
item.isDownloading -> "下载中"
|
||||||
item.isWaiting -> "等待下载"
|
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.domain.usecase.ClearBookCacheUseCase
|
||||||
import io.legado.app.help.book.BookHelp
|
import io.legado.app.help.book.BookHelp
|
||||||
import io.legado.app.help.book.isAudio
|
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.isLocal
|
||||||
import io.legado.app.help.book.isNotShelf
|
import io.legado.app.help.book.isNotShelf
|
||||||
import io.legado.app.model.CacheBook
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
@@ -71,6 +76,8 @@ data class BookCacheChapterItem(
|
|||||||
val isDownloading: Boolean,
|
val isDownloading: Boolean,
|
||||||
val isPaused: Boolean,
|
val isPaused: Boolean,
|
||||||
val isError: Boolean,
|
val isError: Boolean,
|
||||||
|
val downloadProgress: Float? = null,
|
||||||
|
val progressLabel: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface BookCacheManageIntent {
|
sealed interface BookCacheManageIntent {
|
||||||
@@ -163,7 +170,13 @@ class BookCacheManageViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
CacheBook.downloadStateFlow.collect { state ->
|
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
|
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) {
|
private fun scheduleBookReload(bookUrl: String, debounceMillis: Long = 80) {
|
||||||
if (bookUrl.isBlank()) return
|
if (bookUrl.isBlank()) return
|
||||||
bookReloadJobs.remove(bookUrl)?.cancel()
|
bookReloadJobs.remove(bookUrl)?.cancel()
|
||||||
@@ -334,7 +361,11 @@ class BookCacheManageViewModel(
|
|||||||
val errorIndices = errorIndices(book.bookUrl)
|
val errorIndices = errorIndices(book.bookUrl)
|
||||||
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
|
val totalCount = bookChapterDao.getChapterCount(book.bookUrl)
|
||||||
val cachedFileCount = cacheFiles.count { it.endsWith(".nb") }
|
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) {
|
if (totalCount == 0 && cacheFiles.isEmpty() && waitingCount == 0 && downloadingCount == 0 && pausedCount == 0 && !book.isNotShelf) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -362,22 +393,71 @@ class BookCacheManageViewModel(
|
|||||||
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
val chapters = bookChapterDao.getChapterCacheInfoList(bookUrl)
|
||||||
val cacheFiles = BookHelp.getChapterFiles(book)
|
val cacheFiles = BookHelp.getChapterFiles(book)
|
||||||
val model = CacheBook.cacheBookMap[bookUrl]
|
val model = CacheBook.cacheBookMap[bookUrl]
|
||||||
|
val bookState = CacheBook.downloadStateFlow.value.books[bookUrl]
|
||||||
val errorIndices = errorIndices(bookUrl)
|
val errorIndices = errorIndices(bookUrl)
|
||||||
return chapters.map { chapter ->
|
return chapters.map { chapter ->
|
||||||
val isPaused = model?.isPaused(chapter.index) == true
|
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(
|
BookCacheChapterItem(
|
||||||
chapterUrl = chapter.url,
|
chapterUrl = chapter.url,
|
||||||
title = chapter.title,
|
title = chapter.title,
|
||||||
index = chapter.index,
|
index = chapter.index,
|
||||||
isCached = cacheFiles.contains(chapter.getFileName()) || chapter.isVolume,
|
isCached = isCached,
|
||||||
isWaiting = !isPaused && model?.isWaiting(chapter.index) == true,
|
isWaiting = isWaiting,
|
||||||
isDownloading = !isPaused && model?.isDownloading(chapter.index) == true,
|
isDownloading = isDownloading,
|
||||||
isPaused = isPaused,
|
isPaused = isPaused,
|
||||||
isError = errorIndices.contains(chapter.index),
|
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> {
|
private fun errorIndices(bookUrl: String): Set<Int> {
|
||||||
return CacheBook.errorIndices(bookUrl)
|
return CacheBook.errorIndices(bookUrl)
|
||||||
}
|
}
|
||||||
@@ -502,7 +582,7 @@ class BookCacheManageViewModel(
|
|||||||
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
|
for (chapter in bookChapterDao.getChapterCacheInfoList(bookUrl)) {
|
||||||
if (
|
if (
|
||||||
chapter.isVolume ||
|
chapter.isVolume ||
|
||||||
cacheFiles.contains(chapter.getFileName()) ||
|
isChapterFullyCached(book, chapter, cacheFiles) ||
|
||||||
model?.isPaused(chapter.index) == true ||
|
model?.isPaused(chapter.index) == true ||
|
||||||
model?.isWaiting(chapter.index) == true ||
|
model?.isWaiting(chapter.index) == true ||
|
||||||
model?.isDownloading(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) {
|
private fun deleteBookCache(bookUrl: String) {
|
||||||
execute {
|
execute {
|
||||||
CacheBook.removeAwait(context, bookUrl)
|
CacheBook.removeAwait(context, bookUrl)
|
||||||
|
|||||||
@@ -83,4 +83,25 @@ class CacheDownloadStateStoreTest {
|
|||||||
assertEquals(0, store.state.totalWaiting)
|
assertEquals(0, store.state.totalWaiting)
|
||||||
assertEquals(0, store.state.totalRunning)
|
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