[优化] 重写缓存界面

This commit is contained in:
HapeLee committed 2026-04-23 13:08:39 +08:00
1 parent e6dbabc5e0
commit f8473077dc
26 files changed
+1643 -846

No files matched your search

-5
View File
@@ -283,11 +283,6 @@
android:name=".ui.book.bookmark.AllBookmarkActivity" android:name=".ui.book.bookmark.AllBookmarkActivity"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<!-- 缓存界面 -->
<activity
android:name=".ui.book.cache.CacheActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
<!-- WebView界面 --> <!-- WebView界面 -->
<activity <activity
android:name=".ui.browser.WebViewActivity" android:name=".ui.browser.WebViewActivity"
@@ -26,6 +26,7 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel import io.legado.app.ui.book.changecover.ChangeCoverViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel
import io.legado.app.ui.book.cache.CacheViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.group.GroupViewModel import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.book.import.local.ImportBookViewModel import io.legado.app.ui.book.import.local.ImportBookViewModel
@@ -37,6 +38,7 @@ import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.cacheConfig.CacheConfig
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
import io.legado.app.ui.config.readConfig.ReadConfigViewModel import io.legado.app.ui.config.readConfig.ReadConfigViewModel
@@ -72,6 +74,7 @@ val appModule = module {
singleOf(::SearchContentRepository) singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository) singleOf(::RemoteBookRepository)
singleOf(::ExploreKindUiUseCase) singleOf(::ExploreKindUiUseCase)
singleOf(::CacheConfig)
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
single<ExploreRepository> { ExploreRepositoryImpl(get()) } single<ExploreRepository> { ExploreRepositoryImpl(get()) }
@@ -122,6 +125,7 @@ val appModule = module {
viewModelOf(::ExploreViewModel) viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel) viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel) viewModelOf(::SearchViewModel)
viewModelOf(::CacheViewModel)
viewModel { (route: ReplaceEditRoute) -> viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel( ReplaceEditViewModel(
@@ -29,6 +29,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.ui.association.OnLineImportActivity import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.association.OpenUrlConfirmActivity import io.legado.app.ui.association.OpenUrlConfirmActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.ChineseUtils import io.legado.app.utils.ChineseUtils
import io.legado.app.utils.EncoderUtils import io.legado.app.utils.EncoderUtils
@@ -130,7 +131,7 @@ interface JsExtensions : JsEncodeUtils {
} }
fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> { fun ajaxAll(urlList: Array<String>, skipRateLimit: Boolean): Array<StrResponse> {
return runBlocking(context) { return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
url, url,
source = getSource(), source = getSource(),
@@ -153,7 +154,7 @@ interface JsExtensions : JsEncodeUtils {
skipRateLimit: Boolean skipRateLimit: Boolean
): Array<StrResponse> { ): Array<StrResponse> {
return runBlocking(context) { return runBlocking(context) {
urlList.asFlow().mapAsync(AppConfig.threadCount) { url -> urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
url, url,
source = getSource(), source = getSource(),
+150 -147
View File
@@ -11,11 +11,11 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
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.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.service.CacheBookService import io.legado.app.service.CacheBookService
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.onEachParallel import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.startService import io.legado.app.utils.startService
@@ -44,79 +44,111 @@ import kotlin.coroutines.CoroutineContext
object CacheBook { object CacheBook {
private data class QueueStats(
val waitingCount: Int,
val downloadingCount: Int
)
private class CacheBookCoordinator {
val taskMap = ConcurrentHashMap<String, CacheBookModel>()
private val processMutex = Mutex()
private val workingState = MutableStateFlow(true)
fun setWorkingState(value: Boolean) {
workingState.value = value
}
suspend fun startProcessJob(context: CoroutineContext) = processMutex.withLock {
setWorkingState(true)
flow {
while (currentCoroutineContext().isActive && taskMap.isNotEmpty()) {
if (!workingState.value) {
workingState.first { it }
}
var emitted = false
taskMap.forEach { (_, model) ->
if (!model.isLoading()) {
emit(model)
emitted = true
}
}
if (!emitted) delay(800)
}
}.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.onEachParallel(OtherConfig.threadCount.coerceAtLeast(1)) {
coroutineScope {
it.download(this, context)
}
}.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.collect()
}
}
private val coordinator = CacheBookCoordinator()
private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64) private val _cacheSuccessFlow = MutableSharedFlow<BookChapter>(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow() val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
private val _downloadSummaryFlow = MutableStateFlow("") private val _downloadSummaryFlow = MutableStateFlow("")
val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow() val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _downloadingIndicesFlow = private val _downloadingIndicesFlow =
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet()) MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow() val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
private val _downloadErrorFlow = private val _downloadErrorFlow =
MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet()) MutableStateFlow<Pair<String, Set<Int>>>("" to emptySet())
val downloadErrorFlow = _downloadErrorFlow.asStateFlow() val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
val successDownloadSet = ConcurrentHashMap.newKeySet<String>()
val errorDownloadMap = ConcurrentHashMap<String, Int>()
private val errorIndexMap = ConcurrentHashMap<String, MutableSet<Int>>()
val cacheBookMap: ConcurrentHashMap<String, CacheBookModel>
get() = coordinator.taskMap
private fun collectQueueStats(): QueueStats {
var waiting = 0
var downloading = 0
cacheBookMap.forEach { (_, model) ->
val (w, d) = model.queueCounts()
waiting += w
downloading += d
}
return QueueStats(waiting, downloading)
}
private fun updateSummary() { private fun updateSummary() {
_downloadSummaryFlow.value = downloadSummary _downloadSummaryFlow.value = downloadSummary
} }
val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
private val workingState = MutableStateFlow(true)
private val mutex = Mutex()
val totalCount: Int
get() {
var total = 0
cacheBookMap.forEach { (_, model) ->
total += model.waitCount + model.onDownloadCount
}
total += successDownloadSet.size
total += errorDownloadMap.size
return total
}
val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size
@Synchronized @Synchronized
fun getOrCreate(bookUrl: String): CacheBookModel? { fun getOrCreate(bookUrl: String): CacheBookModel? {
val book = appDb.bookDao.getBook(bookUrl) ?: return null val book = appDb.bookDao.getBook(bookUrl) ?: return null
val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null val source = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
updateBookSource(bookSource) return getOrCreate(source, book)
var cacheBook = cacheBookMap[bookUrl]
if (cacheBook != null) {
//存在时更新,书源可能会变化,必须更新
cacheBook.bookSource = bookSource
cacheBook.book = book
return cacheBook
}
cacheBook = CacheBookModel(bookSource, book)
cacheBookMap[bookUrl] = cacheBook
return cacheBook
} }
@Synchronized @Synchronized
fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel { fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel {
updateBookSource(bookSource) updateBookSource(bookSource)
var cacheBook = cacheBookMap[book.bookUrl] cacheBookMap[book.bookUrl]?.let { model ->
if (cacheBook != null) { model.bookSource = bookSource
//存在时更新,书源可能会变化,必须更新 model.book = book
cacheBook.bookSource = bookSource return model
cacheBook.book = book
return cacheBook
} }
cacheBook = CacheBookModel(bookSource, book) val model = CacheBookModel(bookSource, book)
cacheBookMap[book.bookUrl] = cacheBook cacheBookMap[book.bookUrl] = model
return cacheBook updateSummary()
return model
} }
private fun updateBookSource(newBookSource: BookSource) { private fun updateBookSource(newBookSource: BookSource) {
cacheBookMap.forEach { cacheBookMap.forEach { (_, model) ->
val model = it.value
if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) { if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) {
model.bookSource = newBookSource model.bookSource = newBookSource
} }
@@ -149,100 +181,60 @@ object CacheBook {
} }
fun close() { fun close() {
cacheBookMap.forEach { it.value.stop() } cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear() cacheBookMap.clear()
successDownloadSet.clear() successDownloadSet.clear()
errorDownloadMap.clear() errorDownloadMap.clear()
errorIndexMap.clear()
updateSummary()
} }
fun setWorkingState(value: Boolean) { fun setWorkingState(value: Boolean) {
workingState.value = value coordinator.setWorkingState(value)
} }
suspend fun startProcessJob(context: CoroutineContext) = mutex.withLock { suspend fun startProcessJob(context: CoroutineContext) {
setWorkingState(true) coordinator.startProcessJob(context)
flow {
while (currentCoroutineContext().isActive && cacheBookMap.isNotEmpty()) {
var emitted = false
cacheBookMap.forEach { (_, model) ->
if (!model.isLoading()) {
emit(model)
emitted = true
}
workingState.first { it }
} }
if (!emitted) { val totalCount: Int
delay(1000) get() {
} val stats = collectQueueStats()
} return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
}.onStart {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.onEachParallel(AppConfig.threadCount) {
coroutineScope {
it.download(this, context)
}
}.onCompletion {
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
updateSummary()
}.collect()
} }
val completedCount: Int
get() = successDownloadSet.size + errorDownloadMap.size
val downloadSummary: String val downloadSummary: String
get() { get() {
return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}" val stats = collectQueueStats()
return "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
} }
val isRun: Boolean val isRun: Boolean
get() { get() {
cacheBookMap.forEach { cacheBookMap.forEach { (_, model) ->
if (it.value.isRun()) { if (model.isRun()) return true
return true
}
} }
return false return false
} }
private val waitCount: Int private fun onTaskQueuesChanged(bookUrl: String) {
get() { updateSummary()
var count = 0 postEvent(EventBus.UP_DOWNLOAD, bookUrl)
cacheBookMap.forEach {
count += it.value.waitCount
}
return count
} }
val onDownloadCount: Int private fun onTaskRemoved(bookUrl: String) {
get() { cacheBookMap.remove(bookUrl)
var count = 0 updateSummary()
cacheBookMap.forEach { postEvent(EventBus.UP_DOWNLOAD, bookUrl)
count += it.value.onDownloadCount
}
return count
} }
val successDownloadSet = linkedSetOf<String>() class CacheBookModel(
val errorDownloadMap = hashMapOf<String, Int>() @Volatile var bookSource: BookSource,
@Volatile var book: Book
class CacheBookModel(var bookSource: BookSource, var book: Book) { ) {
private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
}
private fun notifyErrorChanged() {
val errorIndices = errorDownloadMap
.filterKeys { it.startsWith(book.bookUrl) }
.mapNotNull {
val chapterIndex = it.key.substringAfterLast("_").toIntOrNull()
chapterIndex
}
.toSet()
_downloadErrorFlow.tryEmit(book.bookUrl to errorIndices)
}
private val waitDownloadSet = linkedSetOf<Int>() private val waitDownloadSet = linkedSetOf<Int>()
private val onDownloadSet = linkedSetOf<Int>() private val onDownloadSet = linkedSetOf<Int>()
@@ -251,13 +243,22 @@ object CacheBook {
private var waitingRetry = false private var waitingRetry = false
private var isLoading = false private var isLoading = false
val waitCount get() = waitDownloadSet.size
val onDownloadCount get() = onDownloadSet.size
init { init {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
} }
private fun notifyDownloadSetChanged() {
_downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet())
}
private fun notifyErrorChanged() {
val errors = errorIndexMap[book.bookUrl]?.toSet() ?: emptySet()
_downloadErrorFlow.tryEmit(book.bookUrl to errors)
}
@Synchronized
fun queueCounts(): Pair<Int, Int> = waitDownloadSet.size to onDownloadSet.size
@Synchronized @Synchronized
fun isRun(): Boolean { fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
@@ -269,13 +270,12 @@ object CacheBook {
} }
@Synchronized @Synchronized
fun isLoading(): Boolean { fun isLoading(): Boolean = isLoading
return isLoading
}
@Synchronized @Synchronized
fun setLoading() { fun setLoading() {
isLoading = true isLoading = true
CacheBook.onTaskQueuesChanged(book.bookUrl)
} }
@Synchronized @Synchronized
@@ -284,7 +284,9 @@ object CacheBook {
tasks.clear() tasks.clear()
isStopped = true isStopped = true
isLoading = false isLoading = false
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) onDownloadSet.clear()
notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
} }
@Synchronized @Synchronized
@@ -297,7 +299,8 @@ object CacheBook {
} }
cacheBookMap[book.bookUrl] = this cacheBookMap[book.bookUrl] = this
isLoading = false isLoading = false
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) notifyDownloadSetChanged()
CacheBook.onTaskQueuesChanged(book.bookUrl)
} }
fun addDownload(index: Int) { fun addDownload(index: Int) {
@@ -309,7 +312,9 @@ object CacheBook {
onDownloadSet.remove(chapter.index) onDownloadSet.remove(chapter.index)
successDownloadSet.add(chapter.primaryStr()) successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr())
errorIndexMap[book.bookUrl]?.remove(chapter.index)
notifyDownloadSetChanged() notifyDownloadSetChanged()
notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter) _cacheSuccessFlow.tryEmit(chapter)
} }
@@ -317,22 +322,20 @@ object CacheBook {
private fun onPreError(chapter: BookChapter, error: Throwable) { private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true waitingRetry = true
if (error !is ConcurrentException) { if (error !is ConcurrentException) {
errorDownloadMap[chapter.primaryStr()] = errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
(errorDownloadMap[chapter.primaryStr()] ?: 0) + 1 errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
.add(chapter.index)
} }
onDownloadSet.remove(chapter.index) onDownloadSet.remove(chapter.index)
} }
@Synchronized @Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) { private fun onPostError(chapter: BookChapter, error: Throwable) {
//重试3次 val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) { if (retryCount < 3 && !isStopped) {
waitDownloadSet.add(chapter.index) waitDownloadSet.add(chapter.index)
} else { } else {
AppLog.put( AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
"下载${book.name}-${chapter.title}失败\n${error.localizedMessage}",
error
)
} }
waitingRetry = false waitingRetry = false
} }
@@ -354,12 +357,13 @@ object CacheBook {
@Synchronized @Synchronized
private fun onFinally() { private fun onFinally() {
val bookUrl = book.bookUrl
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) { if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
cacheBookMap.remove(book.bookUrl) CacheBook.onTaskRemoved(bookUrl)
} else {
CacheBook.onTaskQueuesChanged(bookUrl)
} }
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
notifyDownloadSetChanged() notifyDownloadSetChanged()
updateSummary()
} }
/** /**
@@ -370,7 +374,7 @@ object CacheBook {
val chapterIndex = waitDownloadSet.firstOrNull() val chapterIndex = waitDownloadSet.firstOrNull()
if (chapterIndex == null) { if (chapterIndex == null) {
if (!isLoading && onDownloadSet.isEmpty()) { if (!isLoading && onDownloadSet.isEmpty()) {
cacheBookMap.remove(book.bookUrl) CacheBook.onTaskRemoved(book.bookUrl)
} }
return return
} }
@@ -378,12 +382,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex) waitDownloadSet.remove(chapterIndex)
return return
} }
val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let { val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
waitDownloadSet.remove(chapterIndex) waitDownloadSet.remove(chapterIndex)
return return
} }
if (chapter.isVolume) { if (chapter.isVolume) {
/** 修正下载计数 */
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter)) postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
_cacheSuccessFlow.tryEmit(chapter) _cacheSuccessFlow.tryEmit(chapter)
waitDownloadSet.remove(chapterIndex) waitDownloadSet.remove(chapterIndex)
@@ -393,9 +396,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex) waitDownloadSet.remove(chapterIndex)
return return
} }
waitDownloadSet.remove(chapterIndex) waitDownloadSet.remove(chapterIndex)
onDownloadSet.add(chapterIndex) onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged() notifyDownloadSetChanged()
if (BookHelp.hasContent(book, chapter)) { if (BookHelp.hasContent(book, chapter)) {
Coroutine.async(scope, context, executeContext = context) { Coroutine.async(scope, context, executeContext = context) {
BookHelp.getContent(book, chapter)?.let { BookHelp.getContent(book, chapter)?.let {
@@ -405,7 +410,6 @@ object CacheBook {
onSuccess(chapter) onSuccess(chapter)
}.onError { }.onError {
onPreError(chapter, it) onPreError(chapter, it)
//出现错误等待一秒后重新加入待下载列表
delay(1000) delay(1000)
onPostError(chapter, it) onPostError(chapter, it)
}.onCancel { }.onCancel {
@@ -415,6 +419,7 @@ object CacheBook {
} }
return return
} }
WebBook.getContent( WebBook.getContent(
scope, scope,
bookSource, bookSource,
@@ -428,7 +433,6 @@ object CacheBook {
downloadFinish(chapter, content) downloadFinish(chapter, content)
}.onError { }.onError {
onPreError(chapter, it) onPreError(chapter, it)
//出现错误等待一秒后重新加入待下载列表
delay(1000) delay(1000)
onPostError(chapter, it) onPostError(chapter, it)
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}") downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}")
@@ -445,6 +449,7 @@ object CacheBook {
synchronized(this) { synchronized(this) {
onDownloadSet.add(chapter.index) onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index) waitDownloadSet.remove(chapter.index)
notifyDownloadSetChanged()
} }
try { try {
val content = WebBook.getContentAwait(bookSource, book, chapter) val content = WebBook.getContentAwait(bookSource, book, chapter)
@@ -453,15 +458,13 @@ object CacheBook {
ReadBook.downloadFailChapters.remove(chapter.index) ReadBook.downloadFailChapters.remove(chapter.index)
return content return content
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) { if (e is CancellationException) onCancel(chapter.index)
onCancel(chapter.index)
}
onError(chapter, e) onError(chapter, e)
ReadBook.downloadFailChapters[chapter.index] = ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
return "获取正文失败\n${e.localizedMessage}" return "获取正文失败\n${e.localizedMessage}"
} finally { } finally {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) CacheBook.onTaskQueuesChanged(book.bookUrl)
} }
} }
@@ -472,11 +475,11 @@ object CacheBook {
semaphore: Semaphore?, semaphore: Semaphore?,
resetPageOffset: Boolean = false resetPageOffset: Boolean = false
) { ) {
if (onDownloadSet.contains(chapter.index)) { if (onDownloadSet.contains(chapter.index)) return
return
}
onDownloadSet.add(chapter.index) onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index) waitDownloadSet.remove(chapter.index)
notifyDownloadSetChanged()
WebBook.getContent( WebBook.getContent(
scope, scope,
bookSource, bookSource,
@@ -499,7 +502,7 @@ object CacheBook {
onCancel(chapter.index) onCancel(chapter.index)
downloadFinish(chapter, "download canceled", resetPageOffset, true) downloadFinish(chapter, "download canceled", resetPageOffset, true)
}.onFinally { }.onFinally {
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) CacheBook.onTaskQueuesChanged(book.bookUrl)
}.start() }.start()
} }
@@ -511,13 +514,13 @@ object CacheBook {
) { ) {
if (ReadBook.book?.bookUrl == book.bookUrl) { if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.contentLoadFinish( ReadBook.contentLoadFinish(
book, chapter, content, book = book,
chapter = chapter,
content = content,
resetPageOffset = resetPageOffset, resetPageOffset = resetPageOffset,
canceled = canceled canceled = canceled
) )
} }
} }
} }
} }
@@ -7,7 +7,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.search.SearchScope
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
@@ -33,7 +33,7 @@ import kotlin.coroutines.coroutineContext
import kotlin.math.min import kotlin.math.min
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) { class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
val threadCount = AppConfig.threadCount val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null private var searchPool: ExecutorCoroutineDispatcher? = null
private var mSearchId = 0L private var mSearchId = 0L
private var searchPage = 1 private var searchPage = 1
@@ -12,10 +12,10 @@ import io.legado.app.constant.IntentAction
import io.legado.app.constant.NotificationId import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.help.book.update import io.legado.app.help.book.update
import io.legado.app.help.config.AppConfig
import io.legado.app.model.CacheBook import io.legado.app.model.CacheBook
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.servicePendingIntent import io.legado.app.utils.servicePendingIntent
@@ -41,7 +41,7 @@ class CacheBookService : BaseService() {
private set private set
} }
private val threadCount = AppConfig.threadCount private val threadCount = OtherConfig.threadCount
private var cachePool = private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null private var downloadJob: Job? = null
@@ -53,7 +53,12 @@ class CacheBookService : BaseService() {
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(getString(R.string.offline_cache)) .setContentTitle(getString(R.string.offline_cache))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity")) .setContentIntent(
activityPendingIntent(
MainActivity.createCacheIntent(this),
"cacheActivity"
)
)
builder.addAction( builder.addAction(
R.drawable.ic_stop_black_24dp, R.drawable.ic_stop_black_24dp,
getString(R.string.cancel), getString(R.string.cancel),
@@ -18,12 +18,12 @@ import io.legado.app.exception.ContentEmptyException
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.exception.TocEmptyException import io.legado.app.exception.TocEmptyException
import io.legado.app.help.IntentData import io.legado.app.help.IntentData
import io.legado.app.help.config.AppConfig
import io.legado.app.help.source.exploreKinds import io.legado.app.help.source.exploreKinds
import io.legado.app.model.CheckSource import io.legado.app.model.CheckSource
import io.legado.app.model.Debug import io.legado.app.model.Debug
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.onEachParallel import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
@@ -51,7 +51,7 @@ import kotlin.math.min
* 校验书源 * 校验书源
*/ */
class CheckSourceService : BaseService() { class CheckSourceService : BaseService() {
private var threadCount = AppConfig.threadCount private var threadCount = OtherConfig.threadCount
private var searchCoroutine = private var searchCoroutine =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher() Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var notificationMsg = appCtx.getString(R.string.service_starting) private var notificationMsg = appCtx.getString(R.string.service_starting)
@@ -25,7 +25,7 @@ import io.legado.app.help.book.isLocalModified
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.FileDoc import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.HtmlFormatter import io.legado.app.utils.HtmlFormatter
@@ -48,6 +48,8 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@@ -79,6 +81,8 @@ class ExportBookService : BaseService() {
companion object { companion object {
val exportProgress = ConcurrentHashMap<String, Int>() val exportProgress = ConcurrentHashMap<String, Int>()
val exportMsg = ConcurrentHashMap<String, String>() val exportMsg = ConcurrentHashMap<String, String>()
private val _exportBookUpdateFlow = MutableSharedFlow<String>(extraBufferCapacity = 64)
val exportBookUpdateFlow = _exportBookUpdateFlow.asSharedFlow()
} }
data class ExportConfig( data class ExportConfig(
@@ -107,7 +111,7 @@ class ExportBookService : BaseService() {
) )
waitExportBooks[bookUrl] = exportConfig waitExportBooks[bookUrl] = exportConfig
exportMsg[bookUrl] = getString(R.string.export_wait) exportMsg[bookUrl] = getString(R.string.export_wait)
postEvent(EventBus.EXPORT_BOOK, bookUrl) notifyExportBookChanged(bookUrl)
export() export()
} }
}.onFailure { }.onFailure {
@@ -127,7 +131,7 @@ class ExportBookService : BaseService() {
exportProgress.clear() exportProgress.clear()
exportMsg.clear() exportMsg.clear()
waitExportBooks.keys.forEach { waitExportBooks.keys.forEach {
postEvent(EventBus.EXPORT_BOOK, it) notifyExportBookChanged(it)
} }
} }
@@ -146,7 +150,12 @@ class ExportBookService : BaseService() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload) val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export) .setSmallIcon(R.drawable.ic_export)
.setSubText(getString(R.string.export_book)) .setSubText(getString(R.string.export_book))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity")) .setContentIntent(
activityPendingIntent(
MainActivity.createCacheIntent(this),
"cacheActivity"
)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentText(notificationContentText) .setContentText(notificationContentText)
.setDeleteIntent(servicePendingIntent<ExportBookService>(IntentAction.stop)) .setDeleteIntent(servicePendingIntent<ExportBookService>(IntentAction.stop))
@@ -206,7 +215,7 @@ class ExportBookService : BaseService() {
AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e) AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
} finally { } finally {
exportProgress.remove(bookUrl) exportProgress.remove(bookUrl)
postEvent(EventBus.EXPORT_BOOK, bookUrl) notifyExportBookChanged(bookUrl)
} }
} }
} }
@@ -234,7 +243,7 @@ class ExportBookService : BaseService() {
private suspend fun exportTxt(path: String, book: Book) { private suspend fun exportTxt(path: String, book: Book) {
exportMsg.remove(book.bookUrl) exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl) notifyExportBookChanged(book.bookUrl)
val fileDoc = FileDoc.fromDir(path) val fileDoc = FileDoc.fromDir(path)
exportTxt(fileDoc, book) exportTxt(fileDoc, book)
} }
@@ -296,13 +305,18 @@ class ExportBookService : BaseService() {
}.mapAsync(threads) { chapter -> }.mapAsync(threads) { chapter ->
getExportData(book, chapter, contentProcessor, useReplace) getExportData(book, chapter, contentProcessor, useReplace)
}.collectIndexed { index, result -> }.collectIndexed { index, result ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl) notifyExportBookChanged(book.bookUrl)
exportProgress[book.bookUrl] = index exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second) append.invoke(result.first, result.second)
} }
} }
private fun notifyExportBookChanged(bookUrl: String) {
postEvent(EventBus.EXPORT_BOOK, bookUrl)
_exportBookUpdateFlow.tryEmit(bookUrl)
}
private fun getExportData( private fun getExportData(
book: Book, book: Book,
chapter: BookChapter, chapter: BookChapter,
@@ -1,599 +0,0 @@
package io.legado.app.ui.book.cache
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.textfield.TextInputLayout
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppConst.charsets
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.IntentAction
import io.legado.app.data.AppDatabase
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookGroup
import io.legado.app.databinding.ActivityCacheBookBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogSelectSectionExportBinding
import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isAudio
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.CacheBook
import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.ACache
import io.legado.app.utils.FileDoc
import io.legado.app.utils.applyOpenTint
import io.legado.app.utils.applyTint
import io.legado.app.utils.checkWrite
import io.legado.app.utils.cnCompare
import io.legado.app.utils.enableCustomExport
import io.legado.app.utils.flowWithLifecycleAndDatabaseChange
import io.legado.app.utils.iconItemOnLongClick
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.observeEvent
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startService
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.verificationField
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import splitties.init.appCtx
import kotlin.math.max
/**
* cache/download 缓存界面
*/
class CacheActivity : VMBaseActivity<ActivityCacheBookBinding, CacheViewModel>(),
PopupMenu.OnMenuItemClickListener,
CacheAdapter.CallBack {
override val binding by viewBinding(ActivityCacheBookBinding::inflate)
override val viewModel by viewModels<CacheViewModel>()
private val exportBookPathKey = "exportBookPath"
private val exportTypes = arrayListOf("txt", "epub")
private val layoutManager by lazy { LinearLayoutManager(this) }
private val adapter by lazy { CacheAdapter(this, this) }
private var booksFlowJob: Job? = null
private var menu: Menu? = null
private val groupList: ArrayList<BookGroup> = arrayListOf()
private var groupId: Long = -1
private val exportDir = registerForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) {
return@registerForActivityResult
}
if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(dirPath, result.requestCode)
} else {
startExport(dirPath, result.requestCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
groupId = intent.getLongExtra("groupId", -1)
lifecycleScope.launch {
binding.titleBar.subtitle = withContext(IO) {
appDb.bookGroupDao.getByID(groupId)?.groupName
?: getString(R.string.no_group)
}
}
initRecyclerView()
initGroupData()
initBookData()
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_cache, menu)
menu.iconItemOnLongClick(R.id.menu_download) {
PopupMenu(this, it).apply {
inflate(R.menu.book_cache_download)
this.menu.applyOpenTint(this@CacheActivity)
setOnMenuItemClickListener(this@CacheActivity)
}.show()
}
return super.onCompatCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
this.menu = menu
upMenu()
return super.onPrepareOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_replace)?.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace)
// 菜单打开时读取状态[enableCustomExport]
menu.findItem(R.id.menu_enable_custom_export)?.isChecked = AppConfig.enableCustomExport
menu.findItem(R.id.menu_export_no_chapter_name)?.isChecked = AppConfig.exportNoChapterName
menu.findItem(R.id.menu_export_web_dav)?.isChecked = AppConfig.exportToWebDav
menu.findItem(R.id.menu_export_pics_file)?.isChecked = AppConfig.exportPictureFile
menu.findItem(R.id.menu_parallel_export)?.isChecked = AppConfig.parallelExportBook
menu.findItem(R.id.menu_export_type)?.title =
"${getString(R.string.export_type)}(${getTypeName()})"
menu.findItem(R.id.menu_export_charset)?.title =
"${getString(R.string.export_charset)}(${AppConfig.exportCharset})"
return super.onMenuOpened(featureId, menu)
}
private fun upMenu() {
menu?.findItem(R.id.menu_book_group)?.subMenu?.let { subMenu ->
subMenu.removeGroup(R.id.menu_group)
groupList.forEach { bookGroup ->
subMenu.add(R.id.menu_group, bookGroup.order, Menu.NONE, bookGroup.groupName)
}
}
}
/**
* 菜单按下回调
*/
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_download,
R.id.menu_download_after -> {
if (!CacheBook.isRun) sureCacheBook {
adapter.getItems().forEach { book ->
val indices = (book.durChapterIndex..book.lastChapterIndex).toList()
CacheBook.start(this@CacheActivity, book, indices)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_download_all -> {
if (!CacheBook.isRun) sureCacheBook {
adapter.getItems().forEach { book ->
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(this@CacheActivity, book, indices)
}
} else {
CacheBook.stop(this@CacheActivity)
}
}
R.id.menu_export_all -> exportAll()
R.id.menu_enable_replace -> {
AppConfig.exportUseReplace = !AppConfig.exportUseReplace
item.setIcon(if (AppConfig.exportUseReplace) R.drawable.ic_cfg_replace_enable else R.drawable.ic_find_replace)
val message = if (AppConfig.exportUseReplace) {
"替换净化功能已开启"
} else {
"替换净化功能已关闭"
}
toastOnUi(message)
}
// 更改菜单状态[enableCustomExport]
R.id.menu_enable_custom_export -> AppConfig.enableCustomExport = !item.isChecked
R.id.menu_export_no_chapter_name -> AppConfig.exportNoChapterName = !item.isChecked
R.id.menu_export_web_dav -> AppConfig.exportToWebDav = !item.isChecked
R.id.menu_export_pics_file -> AppConfig.exportPictureFile = !item.isChecked
R.id.menu_parallel_export -> AppConfig.parallelExportBook = !item.isChecked
R.id.menu_export_folder -> {
selectExportFolder(-1)
}
R.id.menu_export_file_name -> alertExportFileName()
R.id.menu_export_type -> showExportTypeConfig()
R.id.menu_export_charset -> showCharsetConfig()
R.id.menu_log -> showDialogFragment<AppLogDialog>()
else -> if (item.groupId == R.id.menu_group) {
binding.titleBar.subtitle = item.title
groupId = appDb.bookGroupDao.getByName(item.title.toString())?.groupId ?: 0
initBookData()
}
}
return super.onCompatOptionsItemSelected(item)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return onCompatOptionsItemSelected(item)
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter
}
private fun initBookData() {
booksFlowJob?.cancel()
booksFlowJob = lifecycleScope.launch {
appDb.bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter {
!it.isAudio
}
when (AppConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.flowWithLifecycleAndDatabaseChange(
lifecycle, table = AppDatabase.BOOK_TABLE_NAME
).catch {
AppLog.put("缓存管理界面获取书籍列表失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect { books ->
adapter.setItems(books)
viewModel.loadCacheFiles(books)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun initGroupData() {
lifecycleScope.launch {
appDb.bookGroupDao.flowAll().catch {
AppLog.put("缓存管理界面获取分组数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
groupList.clear()
groupList.addAll(it)
adapter.notifyDataSetChanged()
upMenu()
}
}
}
private fun notifyItemChanged(bookUrl: String) {
kotlin.runCatching {
adapter.getItems().forEachIndexed { index, book ->
if (bookUrl == book.bookUrl) {
adapter.notifyItemChanged(index, true)
return
}
}
}
}
override fun observeLiveBus() {
viewModel.upAdapterLiveData.observe(this) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.EXPORT_BOOK) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD) {
notifyItemChanged(it)
}
observeEvent<String>(EventBus.UP_DOWNLOAD_STATE) {
if (!CacheBook.isRun) {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_play)
item.setTitle(R.string.download_start)
}
menu?.applyTint(this)
} else {
menu?.findItem(R.id.menu_download)?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
menu?.applyTint(this)
}
}
observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
viewModel.cacheChapters[book.bookUrl]?.add(chapter.url)
notifyItemChanged(book.bookUrl)
}
}
override fun export(position: Int) {
val path = ACache.get().getAsString(exportBookPathKey)
lifecycleScope.launch {
if (path.isNullOrEmpty() ||
withContext(IO) { !FileDoc.fromDir(path).checkWrite() }
) {
selectExportFolder(position)
} else if (enableCustomExport()) {// 启用自定义导出 and 导出类型为Epub
configExportSection(path, position)
} else {
startExport(path, position)
}
}
}
private fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(-10)
} else {
startExport(path, -10)
}
}
override fun deleteDownload(book: Book) {
viewModel.clearCacheForBook(book)
notifyItemChanged(book.bookUrl)
}
/**
* 配置自定义导出对话框
*
* @param path 导出路径
* @param position book位置
* @author Discut
* @since 1.0.0
*/
private fun configExportSection(path: String, position: Int) {
val alertBinding = DialogSelectSectionExportBinding.inflate(layoutInflater)
.apply {
fun verifyExportFileNameJsStr(js: String): Boolean {
return tryParesExportFileName(js) && etEpubFilename.text.toString()
.isNotEmpty()
}
fun enableLyEtEpubFilenameIcon() {
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_CUSTOM
lyEtEpubFilename.setEndIconOnClickListener {
adapter.getItem(position)?.run {
lyEtEpubFilename.helperText =
if (verifyExportFileNameJsStr(etEpubFilename.text.toString()))
"${resources.getString(R.string.result_analyzed)}: ${
getExportFileName(
"epub",
1,
etEpubFilename.text.toString()
)
}"
else "Error"
} ?: run {
lyEtEpubFilename.helperText = "Error"
AppLog.put("未找到书籍,position is $position")
}
}
}
etEpubSize.setText("1")
// lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
etEpubFilename.text?.append(AppConfig.episodeExportFileName)
// 存储解析文件名的jsStr
etEpubFilename.let {
it.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus)
return@setOnFocusChangeListener
it.text?.run {
if (verifyExportFileNameJsStr(toString())) {
AppConfig.episodeExportFileName = toString()
}
}
}
}
tvAllExport.setOnClickListener {
cbAllExport.callOnClick()
}
tvSelectExport.setOnClickListener {
cbSelectExport.callOnClick()
}
cbSelectExport.setOnCheckedChangeListener{ _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = true
etInputScope.isEnabled = true
etEpubFilename.isEnabled = true
enableLyEtEpubFilenameIcon()
cbAllExport.isChecked = false
}
}
cbAllExport.setOnCheckedChangeListener{ _, isChecked ->
if (isChecked) {
etEpubSize.isEnabled = false
etInputScope.isEnabled = false
etEpubFilename.isEnabled = false
lyEtEpubFilename.endIconMode = TextInputLayout.END_ICON_NONE
cbSelectExport.isChecked = false
}
}
etInputScope.onFocusChangeListener =
View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
etInputScope.hint = "1-5,8,10-18"
} else {
etInputScope.hint = ""
}
}
// 默认选择自定义导出
cbSelectExport.callOnClick()
}
val alertDialog = alert(titleResource = R.string.select_section_export) {
customView { alertBinding.root }
positiveButton(R.string.ok)
cancelButton()
}
alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
alertBinding.apply {
if (cbAllExport.isChecked) {
startExport(path, position)
alertDialog.hide()
return@apply
}
val epubScope = etInputScope.text.toString()
if (!verificationField(epubScope)) {
etInputScope.error = appCtx.getString(R.string.error_scope_input)//"请输入正确的范围"
return@apply
}
etInputScope.error = null
val epubSize = etEpubSize.text.toString().toIntOrNull() ?: 1
adapter.getItem(position)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", path)
putExtra("epubSize", epubSize)
putExtra("epubScope", epubScope)
}
}
alertDialog.hide()
}
}
}
private fun selectExportFolder(exportPosition: Int) {
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) {
default.add(SelectItem(path, -1))
}
exportDir.launch {
otherActions = default
requestCode = exportPosition
}
}
private fun startExport(path: String, exportPosition: Int) {
val exportType = when (AppConfig.exportType) {
1 -> "epub"
else -> "txt"
}
if (exportPosition == -10) {
if (adapter.getItems().isNotEmpty()) {
adapter.getItems().forEach { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
} else {
toastOnUi(R.string.no_book)
}
} else if (exportPosition >= 0) {
adapter.getItem(exportPosition)?.let { book ->
startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
}
}
@SuppressLint("SetTextI18n")
private fun alertExportFileName() {
alert(R.string.export_file_name) {
val message = """
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
setMessage(message)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editLayout.hint = "书名:《{name}》 作者:{author}"
editView.setText(AppConfig.bookExportFileName)
}
customView { alertBinding.root }
okButton {
AppConfig.bookExportFileName = alertBinding.editView.text?.toString()
}
cancelButton()
}
}
private fun sureCacheBook(action: () -> Unit) {
alert(R.string.draw) {
setMessage(R.string.sure_cache_book)
noButton()
yesButton {
action.invoke()
}
}
}
private fun getTypeName(): String {
return exportTypes.getOrElse(AppConfig.exportType) {
exportTypes[0]
}
}
private fun showExportTypeConfig() {
selector(R.string.export_type, exportTypes) { _, i ->
AppConfig.exportType = i
}
}
private fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset name"
editView.setFilterValues(charsets)
editView.setText(AppConfig.exportCharset)
}
customView { alertBinding.root }
okButton {
AppConfig.exportCharset = alertBinding.editView.text?.toString() ?: "UTF-8"
}
cancelButton()
}
}
override val cacheChapters: HashMap<String, HashSet<String>>
get() = viewModel.cacheChapters
override fun exportProgress(bookUrl: String): Int? {
return ExportBookService.exportProgress[bookUrl]
}
override fun exportMsg(bookUrl: String): String? {
return ExportBookService.exportMsg[bookUrl]
}
}
@@ -1,8 +1,6 @@
package io.legado.app.ui.book.cache package io.legado.app.ui.book.cache
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
@@ -15,7 +13,6 @@ import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.databinding.ItemDownloadBinding import io.legado.app.databinding.ItemDownloadBinding
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.model.CacheBook
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.visible import io.legado.app.utils.visible
@@ -72,8 +69,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
} else { } else {
val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0 val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0
tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum) tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum)
val cacheTask = CacheBook.cacheBookMap[item.bookUrl] if (callBack.isBookDownloading(item.bookUrl)) {
if (cacheTask != null && !cacheTask.isStop()) {
val progress = if (item.totalChapterNum > 0) cacheSize * 100 / item.totalChapterNum else 0 val progress = if (item.totalChapterNum > 0) cacheSize * 100 / item.totalChapterNum else 0
progressDownload.progress = progress progressDownload.progress = progress
progressDownload.visible() progressDownload.visible()
@@ -90,17 +86,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
binding.run { binding.run {
ivDownload.setOnClickListener { ivDownload.setOnClickListener {
getItem(holder.layoutPosition)?.let { book -> getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let { callBack.toggleBookDownload(book)
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
} else {
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(context, book, indices)
}
} ?: let {
val indices = (0..book.lastChapterIndex).toList()
CacheBook.start(context, book, indices)
}
} }
} }
tvExport.setOnClickListener { tvExport.setOnClickListener {
@@ -108,18 +94,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
} }
ivDelete.setOnClickListener { ivDelete.setOnClickListener {
getItem(holder.layoutPosition)?.let { book -> getItem(holder.layoutPosition)?.let { book ->
CacheBook.cacheBookMap[book.bookUrl]?.let {
if (!it.isStop()) {
CacheBook.remove(context, book.bookUrl)
Handler(Looper.getMainLooper()).postDelayed({
callBack.deleteDownload(book) callBack.deleteDownload(book)
}, 10)
} else {
callBack.deleteDownload(book)
}
} ?: run {
callBack.deleteDownload(book)
}
} }
} }
@@ -131,15 +106,12 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
button.gone() button.gone()
} else { } else {
button.visible() button.visible()
CacheBook.cacheBookMap[book.bookUrl]?.let { val iconRes = if (callBack.isBookDownloading(book.bookUrl)) {
if (!it.isStop()) { R.drawable.ic_stop_black_24dp
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp)
} else { } else {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play) R.drawable.ic_play
}
} ?: run {
button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play)
} }
button.icon = ContextCompat.getDrawable(context, iconRes)
} }
} }
@@ -162,7 +134,9 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
} }
interface CallBack { interface CallBack {
val cacheChapters: HashMap<String, HashSet<String>> val cacheChapters: Map<String, Set<String>>
fun isBookDownloading(bookUrl: String): Boolean
fun toggleBookDownload(book: Book)
fun export(position: Int) fun export(position: Int)
fun exportProgress(bookUrl: String): Int? fun exportProgress(bookUrl: String): Int?
fun exportMsg(bookUrl: String): String? fun exportMsg(bookUrl: String): String?
@@ -0,0 +1,769 @@
package io.legado.app.ui.book.cache
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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.constant.IntentAction
import io.legado.app.data.entities.Book
import io.legado.app.help.book.getExportFileName
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.tryParesExportFileName
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.service.ExportBookService
import io.legado.app.ui.about.AppLogSheet
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppLinearProgressIndicator
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.AppFloatingActionButton
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.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.divider.PillDivider
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
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.ACache
import io.legado.app.utils.FileDoc
import io.legado.app.utils.checkWrite
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.startService
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.verificationField
import org.koin.androidx.compose.koinViewModel
@Composable
fun CacheRouteScreen(
groupId: Long,
onBackClick: () -> Unit,
viewModel: CacheViewModel = koinViewModel()
) {
LaunchedEffect(groupId) {
viewModel.dispatch(CacheIntent.Initialize(groupId))
}
CacheScreen(viewModel = viewModel, onBackClick = onBackClick)
}
@Composable
private fun CacheScreen(
viewModel: CacheViewModel,
onBackClick: () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var showMenu by remember { mutableStateOf(false) }
var showGroupMenu by remember { mutableStateOf(false) }
var showFilePickerSheet by remember { mutableStateOf(false) }
var showDownloadConfirmDialog by remember { mutableStateOf(false) }
var showDownloadAllConfirmDialog by remember { mutableStateOf(false) }
var showExportTypeDialog by remember { mutableStateOf(false) }
var showExportFileNameDialog by remember { mutableStateOf(false) }
var showCharsetDialog by remember { mutableStateOf(false) }
var showLogSheet by remember { mutableStateOf(false) }
var showCustomExportDialog by remember { mutableStateOf(false) }
var pendingExportBookUrl by remember { mutableStateOf<String?>(null) }
var pendingExportAll by remember { mutableStateOf(false) }
var customExportPath by remember { mutableStateOf("") }
var customExportBook by remember { mutableStateOf<Book?>(null) }
var customExportAllChapter by remember { mutableStateOf(false) }
var customEpubScopeInput by remember { mutableStateOf("") }
var customEpubScopeError by remember { mutableStateOf<String?>(null) }
var customEpubSizeInput by remember { mutableStateOf("1") }
var customEpisodeExportNameInput by remember { mutableStateOf(state.exportConfig.episodeExportFileName) }
var exportFileNameInput by remember { mutableStateOf(state.exportConfig.bookExportFileName.orEmpty()) }
var exportCharsetInput by remember { mutableStateOf(state.exportConfig.exportCharset) }
val exportBookPathKey = remember { "exportBookPath" }
val exportTypes = remember { arrayListOf("txt", "epub") }
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val exportFolderText = stringResource(R.string.export_folder)
val exportAllText = stringResource(R.string.export_all)
val exportChapterIndexText = stringResource(R.string.export_chapter_index)
val fileContainsNumberText = stringResource(R.string.file_contains_number)
val exportFileNameText = stringResource(R.string.export_file_name)
val resultAnalyzedText = stringResource(R.string.result_analyzed)
val errorScopeInputText = stringResource(R.string.error_scope_input)
val exportFileNameHintText = "书名:《{name}》 作者:{author}"
val exportFileNameHelpText = """
支持变量:{name}(书名)、{author}(作者)、{group}(分组)、{source}(书源)、{remark}(备注)。
可以在字段前后加任意字符。
示例:
书名:《{name}》 作者:{author}
输出:书名:《三体》 作者:刘慈欣
书名:《{name}》-作者:{author}_备注
输出:书名:《三体》-作者:刘慈欣_备注
""".trimIndent()
val exportDir = rememberLauncherForActivityResult(HandleFileContract()) { result ->
var isReadyPath = false
var dirPath = ""
result.uri?.let { uri ->
if (uri.isContentScheme()) {
ACache.get().put(exportBookPathKey, uri.toString())
dirPath = uri.toString()
isReadyPath = true
} else {
uri.path?.let { path ->
ACache.get().put(exportBookPathKey, path)
dirPath = path
isReadyPath = true
}
}
}
if (!isReadyPath) return@rememberLauncherForActivityResult
if (pendingExportAll) {
state.books.forEach { book ->
startExport(context, dirPath, book, state.exportConfig.exportType)
}
return@rememberLauncherForActivityResult
}
val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
val book = state.books.firstOrNull { it.bookUrl == bookUrl } ?: return@rememberLauncherForActivityResult
if (state.exportConfig.enableCustomExport) {
customExportPath = dirPath
customExportBook = book
customExportAllChapter = false
customEpubScopeInput = ""
customEpubScopeError = null
customEpubSizeInput = "1"
customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
showCustomExportDialog = true
} else {
startExport(context, dirPath, book, state.exportConfig.exportType)
}
}
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CacheEffect.ShowMessage -> context.toastOnUi(effect.message)
is CacheEffect.NotifyBookChanged -> Unit
}
}
}
fun selectExportFolder(bookUrl: String? = null, forAll: Boolean = false) {
pendingExportBookUrl = bookUrl
pendingExportAll = forAll
showFilePickerSheet = true
}
fun exportBook(book: Book) {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
selectExportFolder(book.bookUrl)
} else if (state.exportConfig.enableCustomExport) {
customExportPath = path
customExportBook = book
customExportAllChapter = false
customEpubScopeInput = ""
customEpubScopeError = null
customEpubSizeInput = "1"
customEpisodeExportNameInput = state.exportConfig.episodeExportFileName
showCustomExportDialog = true
} else {
startExport(context, path, book, state.exportConfig.exportType)
}
}
fun exportAll() {
val path = ACache.get().getAsString(exportBookPathKey)
if (path.isNullOrEmpty()) {
selectExportFolder(forAll = true)
} else {
state.books.forEach { book ->
startExport(context, path, book, state.exportConfig.exportType)
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
GlassMediumFlexibleTopAppBar(
title = state.groupName ?: stringResource(R.string.offline_cache),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
},
actions = {
if (state.groupList.isNotEmpty()) {
TopBarActionButton(
onClick = { showGroupMenu = true },
imageVector = AppIcons.Filter,
contentDescription = null
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
state.groupList.forEach { group ->
RoundDropdownMenuItem(
text = "${stringResource(R.string.group)}: ${group.groupName}",
isSelected = group.groupId == state.groupId,
onClick = {
dismiss()
viewModel.dispatch(CacheIntent.ChangeGroup(group.groupId))
}
)
}
}
}
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
RoundDropdownMenuItem(
text = stringResource(R.string.download_all),
onClick = {
showMenu = false
if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload)
} else {
showDownloadAllConfirmDialog = true
}
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_all),
onClick = { showMenu = false; exportAll() }
)
PillDivider()
RoundDropdownMenuItem(
text = stringResource(R.string.export_folder),
onClick = { showMenu = false; selectExportFolder() }
)
RoundDropdownMenuItem(
text = stringResource(R.string.export_file_name),
onClick = {
showMenu = false
exportFileNameInput = state.exportConfig.bookExportFileName.orEmpty()
showExportFileNameDialog = true
}
)
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_type)} (${exportTypes.getOrElse(state.exportConfig.exportType) { exportTypes[0] }})",
onClick = {
showMenu = false
showExportTypeDialog = true
}
)
RoundDropdownMenuItem(
text = "${stringResource(R.string.export_charset)} (${state.exportConfig.exportCharset})",
onClick = {
showMenu = false
exportCharsetInput = state.exportConfig.exportCharset
showCharsetDialog = true
}
)
PillDivider()
RoundDropdownMenuItem(
text = "替换净化",
isSelected = state.exportConfig.exportUseReplace,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportUseReplace(!state.exportConfig.exportUseReplace)
)
}
)
RoundDropdownMenuItem(
text = "自定义导出",
isSelected = state.exportConfig.enableCustomExport,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetEnableCustomExport(!state.exportConfig.enableCustomExport)
)
}
)
RoundDropdownMenuItem(
text = "导出包含章节名",
isSelected = !state.exportConfig.exportNoChapterName,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportNoChapterName(!state.exportConfig.exportNoChapterName)
)
}
)
RoundDropdownMenuItem(
text = "导出到WebDav",
isSelected = state.exportConfig.exportToWebDav,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportToWebDav(!state.exportConfig.exportToWebDav)
)
}
)
RoundDropdownMenuItem(
text = "导出插图文件",
isSelected = state.exportConfig.exportPictureFile,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetExportPictureFile(!state.exportConfig.exportPictureFile)
)
}
)
RoundDropdownMenuItem(
text = "并行导出",
isSelected = state.exportConfig.parallelExportBook,
onClick = {
showMenu = false
viewModel.dispatch(
CacheIntent.SetParallelExportBook(!state.exportConfig.parallelExportBook)
)
}
)
PillDivider()
RoundDropdownMenuItem(
text = stringResource(R.string.log),
onClick = {
showMenu = false
showLogSheet = true
}
)
}
}
)
},
floatingActionButton = {
AppFloatingActionButton(
onClick = {
if (state.isDownloadRunning) {
viewModel.dispatch(CacheIntent.StopDownload)
} else {
showDownloadConfirmDialog = true
}
}
) {
Icon(
imageVector = if (state.isDownloadRunning) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null
)
}
}
) { paddingValues ->
val renderVersion by rememberUpdatedState(state.cacheVersion)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.books, key = { it.bookUrl }) { book ->
val cacheCount = remember(renderVersion, book.bookUrl) {
viewModel.getCacheChapters(book.bookUrl)?.size ?: 0
}
val isDownloading = remember(renderVersion, book.bookUrl) {
viewModel.isBookDownloading(book.bookUrl)
}
val exportMsg = remember(renderVersion, book.bookUrl) {
ExportBookService.exportMsg[book.bookUrl]
}
val exportProgress = remember(renderVersion, book.bookUrl) {
ExportBookService.exportProgress[book.bookUrl]
}
GlassCard(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
AppText(
text = book.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
TextCard(text = stringResource(R.string.download_count, cacheCount, book.totalChapterNum))
}
AppText(
text = stringResource(R.string.author_show, book.getRealAuthor()),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppText(
text = if (book.isLocal) {
stringResource(R.string.local_book)
} else {
stringResource(R.string.download_count, cacheCount, book.totalChapterNum)
}
)
if (isDownloading && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (cacheCount.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
}
if (exportMsg != null) {
AppText(text = exportMsg, modifier = Modifier.padding(top = 6.dp))
} else if (exportProgress != null && book.totalChapterNum > 0) {
AppLinearProgressIndicator(
progress = (exportProgress.toFloat() / book.totalChapterNum.toFloat()).coerceIn(0f, 1f),
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End
) {
SmallTonalIconButton(
onClick = {
if (!book.isLocal) {
viewModel.dispatch(CacheIntent.ToggleBookDownload(book))
}
},
imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
contentDescription = null
)
SmallTonalIconButton(
onClick = { exportBook(book) },
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Upload,
contentDescription = null
)
SmallTonalIconButton(
onClick = {
viewModel.dispatch(CacheIntent.DeleteBookDownload(book.bookUrl))
viewModel.dispatch(CacheIntent.ClearBookCache(book))
},
modifier = Modifier.padding(start = 8.dp),
imageVector = Icons.Default.Delete,
contentDescription = null
)
}
}
}
}
}
}
FilePickerSheet(
show = showFilePickerSheet,
onDismissRequest = { showFilePickerSheet = false },
title = exportFolderText,
onSelectSysDir = {
showFilePickerSheet = false
val default = arrayListOf<SelectItem<Int>>()
val path = ACache.get().getAsString(exportBookPathKey)
if (!path.isNullOrEmpty()) default.add(SelectItem(path, -1))
exportDir.launch {
mode = HandleFileContract.DIR_SYS
title = exportFolderText
otherActions = default
requestCode = 0
}
}
)
AppAlertDialog(
show = showDownloadConfirmDialog,
onDismissRequest = { showDownloadConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDownloadConfirmDialog = false
viewModel.dispatch(
CacheIntent.StartDownloadForVisibleBooks(
books = state.books,
downloadAllChapters = false
)
)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDownloadConfirmDialog = false }
)
AppAlertDialog(
show = showDownloadAllConfirmDialog,
onDismissRequest = { showDownloadAllConfirmDialog = false },
title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok),
onConfirm = {
showDownloadAllConfirmDialog = false
viewModel.dispatch(
CacheIntent.StartDownloadForVisibleBooks(
books = state.books,
downloadAllChapters = true
)
)
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showDownloadAllConfirmDialog = false }
)
OptionSheet(
show = showExportTypeDialog,
onDismissRequest = { showExportTypeDialog = false },
title = stringResource(R.string.export_type)
) {
exportTypes.forEachIndexed { index, type ->
OptionCard(
icon = if (type == "epub") Icons.Default.Upload else Icons.Default.Download,
text = type,
onClick = {
viewModel.dispatch(CacheIntent.SetExportType(index))
showExportTypeDialog = false
}
)
}
}
AppAlertDialog(
show = showExportFileNameDialog,
onDismissRequest = { showExportFileNameDialog = false },
title = stringResource(R.string.export_file_name),
content = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
AppText(text = exportFileNameHelpText)
AppTextField(
value = exportFileNameInput,
onValueChange = { exportFileNameInput = it },
modifier = Modifier.fillMaxWidth(),
label = exportFileNameText,
placeholder = { AppText(exportFileNameHintText) }
)
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
viewModel.dispatch(CacheIntent.SetBookExportFileName(exportFileNameInput))
showExportFileNameDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showExportFileNameDialog = false }
)
AppAlertDialog(
show = showCharsetDialog,
onDismissRequest = { showCharsetDialog = false },
title = stringResource(R.string.set_charset),
content = {
AppTextField(
value = exportCharsetInput,
onValueChange = { exportCharsetInput = it },
modifier = Modifier.fillMaxWidth(),
label = stringResource(R.string.set_charset)
)
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
viewModel.dispatch(
CacheIntent.SetExportCharset(
exportCharsetInput.ifBlank { "UTF-8" }
)
)
showCharsetDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { showCharsetDialog = false }
)
AppLogSheet(
show = showLogSheet,
onDismissRequest = { showLogSheet = false }
)
val currentCustomBook = customExportBook
AppAlertDialog(
show = showCustomExportDialog && currentCustomBook != null,
onDismissRequest = {
showCustomExportDialog = false
customEpubScopeError = null
},
title = stringResource(R.string.select_section_export),
content = {
val episodeTemplateValid = customEpisodeExportNameInput.isNotBlank()
&& tryParesExportFileName(customEpisodeExportNameInput)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(
selected = customExportAllChapter,
onClick = { customExportAllChapter = true }
)
AppText(
text = exportAllText,
modifier = Modifier.padding(top = 12.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(
selected = !customExportAllChapter,
onClick = { customExportAllChapter = false }
)
AppText(
text = exportChapterIndexText,
modifier = Modifier.padding(top = 12.dp)
)
}
if (!customExportAllChapter) {
AppTextField(
value = customEpubScopeInput,
onValueChange = {
customEpubScopeInput = it
customEpubScopeError = null
},
modifier = Modifier.fillMaxWidth(),
label = exportChapterIndexText,
placeholder = { AppText("1-5,8,10-18") },
supportingText = {
customEpubScopeError?.let { msg ->
AppText(text = msg)
}
},
isError = customEpubScopeError != null
)
AppTextField(
value = customEpubSizeInput,
onValueChange = { customEpubSizeInput = it },
modifier = Modifier.fillMaxWidth(),
label = fileContainsNumberText
)
AppTextField(
value = customEpisodeExportNameInput,
onValueChange = { customEpisodeExportNameInput = it },
modifier = Modifier.fillMaxWidth(),
label = exportFileNameText,
placeholder = { AppText(exportFileNameHintText) }
)
if (episodeTemplateValid) {
AppText(
text = "$resultAnalyzedText: ${
currentCustomBook?.getExportFileName(
"epub",
1,
customEpisodeExportNameInput
).orEmpty()
}"
)
} else if (customEpisodeExportNameInput.isNotBlank()) {
AppText(text = "Error")
}
}
}
},
confirmText = stringResource(android.R.string.ok),
onConfirm = {
val book = customExportBook ?: return@AppAlertDialog
if (customExportAllChapter) {
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", customExportPath)
}
showCustomExportDialog = false
return@AppAlertDialog
}
if (!verificationField(customEpubScopeInput)) {
customEpubScopeError = errorScopeInputText
return@AppAlertDialog
}
customEpubScopeError = null
if (customEpisodeExportNameInput.isNotBlank() && tryParesExportFileName(
customEpisodeExportNameInput
)
) {
viewModel.dispatch(CacheIntent.SetEpisodeExportFileName(customEpisodeExportNameInput))
}
val epubSize = customEpubSizeInput.toIntOrNull()?.coerceAtLeast(1) ?: 1
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", "epub")
putExtra("exportPath", customExportPath)
putExtra("epubSize", epubSize)
putExtra("epubScope", customEpubScopeInput)
}
showCustomExportDialog = false
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = {
showCustomExportDialog = false
customEpubScopeError = null
}
)
}
private fun startExport(
context: android.content.Context,
path: String,
book: Book,
exportTypeIndex: Int
) {
val exportType = if (exportTypeIndex == 1) "epub" else "txt"
context.startService<ExportBookService> {
action = IntentAction.start
putExtra("bookUrl", book.bookUrl)
putExtra("exportType", exportType)
putExtra("exportPath", path)
}
}
@@ -1,32 +1,299 @@
package io.legado.app.ui.book.cache package io.legado.app.ui.book.cache
import android.app.Application import android.app.Application
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookGroupDao
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.BookGroup
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.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.CacheBook
import io.legado.app.utils.sendValue import io.legado.app.service.ExportBookService
import io.legado.app.ui.config.cacheConfig.CacheConfig
import io.legado.app.utils.cnCompare
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.math.max
data class CacheExportConfig(
val exportUseReplace: Boolean = true,
val enableCustomExport: Boolean = false,
val exportNoChapterName: Boolean = false,
val exportToWebDav: Boolean = false,
val exportPictureFile: Boolean = false,
val parallelExportBook: Boolean = false,
val exportType: Int = 0,
val exportCharset: String = "UTF-8",
val bookExportFileName: String? = null,
val episodeExportFileName: String = ""
)
class CacheViewModel(application: Application) : BaseViewModel(application) { data class CacheUiState(
val upAdapterLiveData = MutableLiveData<String>() val groupId: Long = -1,
val groupName: String? = null,
val groupList: List<BookGroup> = emptyList(),
val books: List<Book> = emptyList(),
val isDownloadRunning: Boolean = false,
val cacheVersion: Long = 0,
val exportConfig: CacheExportConfig = CacheExportConfig()
)
private var loadChapterCoroutine: Coroutine<Unit>? = null sealed interface CacheIntent {
val cacheChapters = hashMapOf<String, HashSet<String>>() data class Initialize(val groupId: Long) : CacheIntent
data class ChangeGroup(val groupId: Long) : CacheIntent
data class StartDownloadForVisibleBooks(
val books: List<Book>,
val downloadAllChapters: Boolean
) : CacheIntent
data object StopDownload : CacheIntent
data class ToggleBookDownload(val book: Book) : CacheIntent
data class DeleteBookDownload(val bookUrl: String) : CacheIntent
data class ClearBookCache(val book: Book) : CacheIntent
data class SetExportUseReplace(val enabled: Boolean) : CacheIntent
data class SetEnableCustomExport(val enabled: Boolean) : CacheIntent
data class SetExportNoChapterName(val enabled: Boolean) : CacheIntent
data class SetExportToWebDav(val enabled: Boolean) : CacheIntent
data class SetExportPictureFile(val enabled: Boolean) : CacheIntent
data class SetParallelExportBook(val enabled: Boolean) : CacheIntent
data class SetExportType(val type: Int) : CacheIntent
data class SetExportCharset(val charset: String) : CacheIntent
data class SetBookExportFileName(val fileName: String?) : CacheIntent
data class SetEpisodeExportFileName(val fileName: String) : CacheIntent
}
fun loadCacheFiles(books: List<Book>) { sealed interface CacheEffect {
loadChapterCoroutine?.cancel() data class NotifyBookChanged(val bookUrl: String) : CacheEffect
loadChapterCoroutine = execute { data class ShowMessage(val message: String) : CacheEffect
}
class CacheViewModel(
application: Application,
private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao,
private val bookChapterDao: BookChapterDao,
val cacheConfig: CacheConfig
) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(CacheUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<CacheEffect>(extraBufferCapacity = 32)
val effects = _effects.asSharedFlow()
private val cacheChapters = hashMapOf<String, HashSet<String>>()
private var booksJob: Job? = null
private var groupsJob: Job? = null
private var observersStarted = false
fun dispatch(intent: CacheIntent) {
when (intent) {
is CacheIntent.Initialize -> initialize(intent.groupId)
is CacheIntent.ChangeGroup -> changeGroup(intent.groupId)
is CacheIntent.StartDownloadForVisibleBooks -> startDownloadForVisibleBooks(
intent.books,
intent.downloadAllChapters
)
CacheIntent.StopDownload -> CacheBook.stop(context)
is CacheIntent.ToggleBookDownload -> toggleBookDownload(intent.book)
is CacheIntent.DeleteBookDownload -> CacheBook.remove(context, intent.bookUrl)
is CacheIntent.ClearBookCache -> clearCacheForBook(intent.book)
is CacheIntent.SetExportUseReplace -> {
cacheConfig.exportUseReplace = intent.enabled
syncExportConfig()
val msg = if (intent.enabled) "替换净化功能已开启" else "替换净化功能已关闭"
_effects.tryEmit(CacheEffect.ShowMessage(msg))
}
is CacheIntent.SetEnableCustomExport -> {
cacheConfig.enableCustomExport = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportNoChapterName -> {
cacheConfig.exportNoChapterName = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportToWebDav -> {
cacheConfig.exportToWebDav = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportPictureFile -> {
cacheConfig.exportPictureFile = intent.enabled
syncExportConfig()
}
is CacheIntent.SetParallelExportBook -> {
cacheConfig.parallelExportBook = intent.enabled
syncExportConfig()
}
is CacheIntent.SetExportType -> {
cacheConfig.exportType = intent.type
syncExportConfig()
}
is CacheIntent.SetExportCharset -> {
cacheConfig.exportCharset = intent.charset
syncExportConfig()
}
is CacheIntent.SetBookExportFileName -> {
cacheConfig.bookExportFileName = intent.fileName
syncExportConfig()
}
is CacheIntent.SetEpisodeExportFileName -> {
cacheConfig.episodeExportFileName = intent.fileName
syncExportConfig()
}
}
}
fun getCacheChapters(bookUrl: String): Set<String>? = cacheChapters[bookUrl]
fun getAllCacheChapters(): Map<String, Set<String>> = cacheChapters
fun isBookDownloading(bookUrl: String): Boolean {
return CacheBook.cacheBookMap[bookUrl]?.isStop() == false
}
private fun initialize(groupId: Long) {
_uiState.update { it.copy(groupId = groupId) }
syncExportConfig()
observeGroups()
observeBooks(groupId)
observeDownloadAndExportChanges()
refreshGroupName(groupId)
}
private fun changeGroup(groupId: Long) {
_uiState.update { it.copy(groupId = groupId) }
observeBooks(groupId)
refreshGroupName(groupId)
}
private fun observeGroups() {
groupsJob?.cancel()
groupsJob = viewModelScope.launch {
bookGroupDao.flowAll().collect { groups ->
_uiState.update { it.copy(groupList = groups) }
}
}
}
private fun observeBooks(groupId: Long) {
booksJob?.cancel()
booksJob = viewModelScope.launch {
bookDao.flowByGroup(groupId).map { books ->
val booksDownload = books.filter { !it.isAudio }
when (cacheConfig.getBookSortByGroupId(groupId)) {
1 -> booksDownload.sortedByDescending { it.latestChapterTime }
2 -> booksDownload.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) }
3 -> booksDownload.sortedBy { it.order }
4 -> booksDownload.sortedByDescending {
max(it.latestChapterTime, it.durChapterTime)
}
else -> booksDownload.sortedByDescending { it.durChapterTime }
}
}.collect { books ->
_uiState.update { it.copy(books = books) }
loadCacheFiles(books)
}
}
}
private fun observeDownloadAndExportChanges() {
if (observersStarted) return
observersStarted = true
viewModelScope.launch {
CacheBook.cacheSuccessFlow.collect { chapter ->
onChapterCached(chapter)
}
}
viewModelScope.launch {
CacheBook.downloadingIndicesFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
}
}
viewModelScope.launch {
CacheBook.downloadErrorFlow.collect { (bookUrl, _) ->
syncDownloadRunning()
if (bookUrl.isNotEmpty()) {
emitBookChanged(bookUrl)
}
}
}
viewModelScope.launch {
CacheBook.downloadSummaryFlow.collect {
syncDownloadRunning()
}
}
viewModelScope.launch {
ExportBookService.exportBookUpdateFlow.collect { bookUrl ->
emitBookChanged(bookUrl)
}
}
}
private fun syncExportConfig() {
_uiState.update {
it.copy(
exportConfig = CacheExportConfig(
exportUseReplace = cacheConfig.exportUseReplace,
enableCustomExport = cacheConfig.enableCustomExport,
exportNoChapterName = cacheConfig.exportNoChapterName,
exportToWebDav = cacheConfig.exportToWebDav,
exportPictureFile = cacheConfig.exportPictureFile,
parallelExportBook = cacheConfig.parallelExportBook,
exportType = cacheConfig.exportType,
exportCharset = cacheConfig.exportCharset,
bookExportFileName = cacheConfig.bookExportFileName,
episodeExportFileName = cacheConfig.episodeExportFileName
)
)
}
}
private fun syncDownloadRunning() {
_uiState.update { it.copy(isDownloadRunning = CacheBook.isRun) }
}
private fun refreshGroupName(groupId: Long) {
execute {
val title = bookGroupDao.getByID(groupId)?.groupName
title ?: context.getString(io.legado.app.R.string.no_group)
}.onSuccess { groupName ->
_uiState.update { it.copy(groupName = groupName) }
}
}
private fun loadCacheFiles(books: List<Book>) {
execute {
books.forEach { book -> books.forEach { book ->
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) { if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
val chapterCaches = hashSetOf<String>() val chapterCaches = hashSetOf<String>()
val cacheNames = BookHelp.getChapterFiles(book) val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isNotEmpty()) { if (cacheNames.isNotEmpty()) {
appDb.bookChapterDao.getChapterList(book.bookUrl).also { bookChapterDao.getChapterList(book.bookUrl).also {
book.totalChapterNum = it.size book.totalChapterNum = it.size
}.forEach { chapter -> }.forEach { chapter ->
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) { if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
@@ -35,17 +302,51 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
} }
} }
cacheChapters[book.bookUrl] = chapterCaches cacheChapters[book.bookUrl] = chapterCaches
upAdapterLiveData.sendValue(book.bookUrl) emitBookChanged(book.bookUrl)
} }
ensureActive() ensureActive()
} }
} }
} }
fun clearCacheForBook(book: Book) { private fun onChapterCached(chapter: BookChapter) {
val bookUrl = chapter.bookUrl
val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() }
chapterSet.add(chapter.url)
emitBookChanged(bookUrl)
}
private fun startDownloadForVisibleBooks(books: List<Book>, downloadAllChapters: Boolean) {
books.forEach { book ->
val indices = if (downloadAllChapters) {
(0..book.lastChapterIndex).toList()
} else {
(book.durChapterIndex..book.lastChapterIndex).toList()
}
CacheBook.start(context, book, indices)
}
syncDownloadRunning()
}
private fun toggleBookDownload(book: Book) {
if (book.isLocal) return
if (isBookDownloading(book.bookUrl)) {
CacheBook.remove(context, book.bookUrl)
} else {
CacheBook.start(context, book, (0..book.lastChapterIndex).toList())
}
syncDownloadRunning()
}
private fun clearCacheForBook(book: Book) {
BookHelp.clearCache(book) BookHelp.clearCache(book)
cacheChapters[book.bookUrl] = hashSetOf() cacheChapters[book.bookUrl] = hashSetOf()
upAdapterLiveData.sendValue(book.bookUrl) emitBookChanged(book.bookUrl)
}
private fun emitBookChanged(bookUrl: String) {
_uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
_effects.tryEmit(CacheEffect.NotifyBookChanged(bookUrl))
} }
} }
@@ -12,8 +12,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.help.config.AppConfig
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.ExecutorCoroutineDispatcher
@@ -36,7 +36,7 @@ import java.util.concurrent.Executors
import kotlin.math.min import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) { class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null private var searchPool: ExecutorCoroutineDispatcher? = null
private var searchSuccess: ((SearchBook) -> Unit)? = null private var searchSuccess: ((SearchBook) -> Unit)? = null
private var upAdapter: (() -> Unit)? = null private var upAdapter: (() -> Unit)? = null
@@ -25,6 +25,7 @@ import io.legado.app.help.config.SourceConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.SourceHelp
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.internString import io.legado.app.utils.internString
import io.legado.app.utils.mapParallel import io.legado.app.utils.mapParallel
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
@@ -59,7 +60,7 @@ import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) { open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
private val threadCount = AppConfig.threadCount private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData<Boolean>() val searchStateData = MutableLiveData<Boolean>()
private val _isSearching = MutableStateFlow(false) private val _isSearching = MutableStateFlow(false)
@@ -28,6 +28,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.ReadManga import io.legado.app.model.ReadManga
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
@@ -201,7 +202,7 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
}.onStart { }.onStart {
// 自动换源 // 自动换源
}.mapParallelSafe(AppConfig.threadCount) { source -> }.mapParallelSafe(OtherConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) { if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book) WebBook.getBookInfoAwait(source, book)
@@ -31,6 +31,7 @@ import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.page.entities.TextChapter import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
@@ -307,7 +308,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
} }
}.onStart { }.onStart {
ReadBook.upMsg(context.getString(R.string.source_auto_changing)) ReadBook.upMsg(context.getString(R.string.source_auto_changing))
}.mapParallelSafe(AppConfig.threadCount) { source -> }.mapParallelSafe(OtherConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow() val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) { if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book) WebBook.getBookInfoAwait(source, book)
@@ -242,10 +242,6 @@ fun SearchScreen(
onSearch = submitSearch, onSearch = submitSearch,
placeholder = searchLabel, placeholder = searchLabel,
trailingIcon = { trailingIcon = {
Row(
modifier = Modifier.padding(end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (queryInput.isNotEmpty()) { if (queryInput.isNotEmpty()) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
@@ -256,7 +252,6 @@ fun SearchScreen(
contentDescription = stringResource(R.string.clear) contentDescription = stringResource(R.string.clear)
) )
} }
}
}, },
) )
} }
@@ -0,0 +1,36 @@
package io.legado.app.ui.config.cacheConfig
import io.legado.app.constant.PreferKey
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.ui.config.prefDelegate
class CacheConfig(
private val bookGroupDao: BookGroupDao
) {
var bookExportFileName by prefDelegate<String?>(PreferKey.bookExportFileName, null)
var episodeExportFileName by prefDelegate(PreferKey.episodeExportFileName, "")
var exportCharset by prefDelegate(PreferKey.exportCharset, "UTF-8")
var exportUseReplace by prefDelegate(PreferKey.exportUseReplace, true)
var exportToWebDav by prefDelegate(PreferKey.exportToWebDav, false)
var exportNoChapterName by prefDelegate(PreferKey.exportNoChapterName, false)
var enableCustomExport by prefDelegate(PreferKey.enableCustomExport, false)
var exportType by prefDelegate(PreferKey.exportType, 0)
var exportPictureFile by prefDelegate(PreferKey.exportPictureFile, false)
var parallelExportBook by prefDelegate(PreferKey.parallelExportBook, false)
var bookshelfSort by prefDelegate(PreferKey.bookshelfSort, 0)
fun getBookSortByGroupId(groupId: Long): Int {
return bookGroupDao.getByID(groupId)?.getRealBookSort() ?: bookshelfSort
}
}
@@ -13,9 +13,13 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
@@ -37,8 +41,14 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.service.WebService import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.CrashLogsDialog
import io.legado.app.ui.about.UpdateDialog import io.legado.app.ui.about.UpdateDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.import.local.ImportBookScreen import io.legado.app.ui.book.import.local.ImportBookScreen
import io.legado.app.ui.book.import.remote.RemoteBookScreen import io.legado.app.ui.book.import.remote.RemoteBookScreen
import io.legado.app.ui.book.search.SearchIntent
import io.legado.app.ui.book.search.SearchScreen
import io.legado.app.ui.book.search.SearchViewModel
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.book.cache.CacheRouteScreen
import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.config.ConfigNavScreen import io.legado.app.ui.config.ConfigNavScreen
import io.legado.app.ui.config.ConfigTag import io.legado.app.ui.config.ConfigTag
@@ -62,6 +72,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -82,8 +93,13 @@ open class MainActivity : BaseComposeActivity() {
private const val ROUTE_SETTINGS_BACKUP = "settings/backup" private const val ROUTE_SETTINGS_BACKUP = "settings/backup"
private const val ROUTE_IMPORT_LOCAL = "import/local" private const val ROUTE_IMPORT_LOCAL = "import/local"
private const val ROUTE_IMPORT_REMOTE = "import/remote" private const val ROUTE_IMPORT_REMOTE = "import/remote"
private const val ROUTE_CACHE = "cache"
private const val ROUTE_SEARCH = "search"
private const val ROUTE_RSS_SORT = "rss/sort" private const val ROUTE_RSS_SORT = "rss/sort"
private const val ROUTE_RSS_READ = "rss/read" private const val ROUTE_RSS_READ = "rss/read"
private const val EXTRA_CACHE_GROUP_ID = "extra_cache_group_id"
private const val EXTRA_SEARCH_KEY = "extra_search_key"
private const val EXTRA_SEARCH_SCOPE = "extra_search_scope"
private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url" private const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url" private const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
@@ -146,6 +162,28 @@ open class MainActivity : BaseComposeActivity() {
} }
} }
fun createCacheIntent(
context: Context,
groupId: Long = -1L
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_CACHE)
putExtra(EXTRA_CACHE_GROUP_ID, groupId)
}
}
fun createSearchIntent(
context: Context,
key: String? = null,
scopeRaw: String? = null
): Intent {
return createLauncherIntent(context).apply {
putExtra(EXTRA_START_ROUTE, ROUTE_SEARCH)
putExtra(EXTRA_SEARCH_KEY, key)
putExtra(EXTRA_SEARCH_SCOPE, scopeRaw)
}
}
private fun routeForConfigTag(configTag: String?): String { private fun routeForConfigTag(configTag: String?): String {
return when (configTag) { return when (configTag) {
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
@@ -191,6 +229,15 @@ open class MainActivity : BaseComposeActivity() {
@Serializable @Serializable
private data object MainRouteImportRemote : MainRoute private data object MainRouteImportRemote : MainRoute
@Serializable
private data class MainRouteCache(val groupId: Long) : MainRoute
@Serializable
private data class MainRouteSearch(
val key: String?,
val scopeRaw: String? = null
) : MainRoute
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -332,12 +379,23 @@ open class MainActivity : BaseComposeActivity() {
onOpenSettings = { onOpenSettings = {
navigateToRoute(backStack, MainRouteSettings) navigateToRoute(backStack, MainRouteSettings)
}, },
onNavigateToSearch = { key ->
navigateToRoute(
backStack,
MainRouteSearch(
key = key?.trim()?.takeIf { it.isNotEmpty() }
)
)
},
onNavigateToRemoteImport = { onNavigateToRemoteImport = {
navigateToRoute(backStack, MainRouteImportRemote) navigateToRoute(backStack, MainRouteImportRemote)
}, },
onNavigateToLocalImport = { onNavigateToLocalImport = {
navigateToRoute(backStack, MainRouteImportLocal) navigateToRoute(backStack, MainRouteImportLocal)
}, },
onNavigateToCache = { groupId ->
navigateToRoute(backStack, MainRouteCache(groupId))
},
onNavigateToRssSort = { sourceUrl, sortUrl, key -> onNavigateToRssSort = { sourceUrl, sortUrl, key ->
navigateToRoute( navigateToRoute(
backStack, backStack,
@@ -405,6 +463,64 @@ open class MainActivity : BaseComposeActivity() {
) )
} }
entry<MainRouteCache> { route ->
CacheRouteScreen(
groupId = route.groupId,
onBackClick = { navigateBack(backStack) }
)
}
entry<MainRouteSearch> { route ->
val searchViewModel = koinViewModel<SearchViewModel>()
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(route.key, route.scopeRaw, searchViewModel) {
searchViewModel.onIntent(
SearchIntent.Initialize(
key = route.key,
scopeRaw = route.scopeRaw
)
)
}
DisposableEffect(lifecycleOwner, searchViewModel) {
searchViewModel.onIntent(SearchIntent.ResumeEngine)
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
searchViewModel.onIntent(SearchIntent.ResumeEngine)
}
Lifecycle.Event.ON_PAUSE -> {
searchViewModel.onIntent(SearchIntent.PauseEngine)
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
searchViewModel.onIntent(SearchIntent.PauseEngine)
}
}
SearchScreen(
viewModel = searchViewModel,
onBack = { navigateBack(backStack) },
onOpenBookInfo = { name, author, bookUrl ->
this@MainActivity.startActivity<BookInfoActivity> {
putExtra("name", name)
putExtra("author", author)
putExtra("bookUrl", bookUrl)
}
},
onOpenSourceManage = {
this@MainActivity.startActivity<BookSourceActivity>()
}
)
}
entry<MainRouteRssSort> { route -> entry<MainRouteRssSort> { route ->
RssSortRouteScreen( RssSortRouteScreen(
sourceUrl = route.sourceUrl, sourceUrl = route.sourceUrl,
@@ -469,7 +585,18 @@ open class MainActivity : BaseComposeActivity() {
} }
MainRouteImportLocal, MainRouteImportLocal,
MainRouteImportRemote -> { MainRouteImportRemote,
is MainRouteCache -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
} else {
backStack.clear()
backStack.add(MainRouteHome)
backStack.add(route)
}
}
is MainRouteSearch -> {
if (currentRoute == MainRouteHome) { if (currentRoute == MainRouteHome) {
backStack.add(route) backStack.add(route)
} else { } else {
@@ -660,15 +787,20 @@ open class MainActivity : BaseComposeActivity() {
private fun resolveStartRoute(route: String?): MainRoute { private fun resolveStartRoute(route: String?): MainRoute {
return when (route) { return when (route) {
"main" -> MainRouteHome ROUTE_MAIN -> MainRouteHome
"settings" -> MainRouteSettings ROUTE_SETTINGS -> MainRouteSettings
"settings/other" -> MainRouteSettingsOther ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther
"settings/read" -> MainRouteSettingsRead ROUTE_SETTINGS_READ -> MainRouteSettingsRead
"settings/cover" -> MainRouteSettingsCover ROUTE_SETTINGS_COVER -> MainRouteSettingsCover
"settings/theme" -> MainRouteSettingsTheme ROUTE_SETTINGS_THEME -> MainRouteSettingsTheme
"settings/backup" -> MainRouteSettingsBackup ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
"import/local" -> MainRouteImportLocal ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
"import/remote" -> MainRouteImportRemote ROUTE_IMPORT_REMOTE -> MainRouteImportRemote
ROUTE_CACHE -> MainRouteCache(intent?.getLongExtra(EXTRA_CACHE_GROUP_ID, -1L) ?: -1L)
ROUTE_SEARCH -> MainRouteSearch(
key = intent?.getStringExtra(EXTRA_SEARCH_KEY),
scopeRaw = intent?.getStringExtra(EXTRA_SEARCH_SCOPE)
)
else -> MainRouteHome else -> MainRouteHome
} }
} }
@@ -57,7 +57,6 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import io.legado.app.R import io.legado.app.R
import io.legado.app.ui.book.info.BookInfoActivity import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -90,8 +89,10 @@ fun MainScreen(
viewModel: MainViewModel = koinViewModel(), viewModel: MainViewModel = koinViewModel(),
useRail: Boolean, useRail: Boolean,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit, onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) { ) {
@@ -174,7 +175,7 @@ fun MainScreen(
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
modifier = Modifier.padding(start = 20.dp), modifier = Modifier.padding(start = 20.dp),
onClick = { context.startActivity<SearchActivity>() }, onClick = { onNavigateToSearch(null) },
expanded = expanded, expanded = expanded,
icon = { Icon(Icons.Default.Search, contentDescription = null) }, icon = { Icon(Icons.Default.Search, contentDescription = null) },
text = { AppText(stringResource(R.string.search)) } text = { AppText(stringResource(R.string.search)) }
@@ -377,8 +378,10 @@ fun MainScreen(
putExtra("bookUrl", book.bookUrl) putExtra("bookUrl", book.bookUrl)
} }
}, },
onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport, onNavigateToRemoteImport = onNavigateToRemoteImport,
onNavigateToLocalImport = onNavigateToLocalImport onNavigateToLocalImport = onNavigateToLocalImport,
onNavigateToCache = onNavigateToCache
) )
MainDestination.Explore -> ExploreScreen() MainDestination.Explore -> ExploreScreen()
@@ -1,9 +1,10 @@
package io.legado.app.ui.theme package io.legado.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.ThemeEngine.getColorScheme import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@@ -11,20 +12,41 @@ import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@Composable @Composable
fun rememberOpaqueColorScheme(): ColorScheme { fun rememberOpaqueColorScheme(): ColorScheme {
val context = LocalContext.current val context = LocalContext.current
val isDark = isSystemInDarkTheme() val currentTheme = LocalLegadoThemeColors.current
val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme) val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme)
val isDark = currentTheme.isDark
val isPureBlack = ThemeConfig.isPureBlack val isPureBlack = ThemeConfig.isPureBlack
val hasImageBg = ThemeConfig.hasImageBg(isDark) val hasImageBg = ThemeConfig.hasImageBg(isDark)
val paletteStyle = ThemeConfig.paletteStyle val paletteStyle = ThemeConfig.paletteStyle
val materialVersion = ThemeConfig.materialVersion
val seedColorInt = currentTheme.seedColor
.takeUnless { it == Color.Unspecified }
?.toArgb()
return remember(context, appThemeMode, isDark, isPureBlack, hasImageBg, paletteStyle) { return remember(
context,
currentTheme.colorScheme,
appThemeMode,
isDark,
isPureBlack,
hasImageBg,
paletteStyle,
materialVersion,
seedColorInt
) {
if (appThemeMode != AppThemeMode.Transparent) {
currentTheme.colorScheme
} else {
getColorScheme( getColorScheme(
context = context, context = context,
mode = appThemeMode, mode = appThemeMode,
darkTheme = isDark, darkTheme = isDark,
isAmoled = isPureBlack, isAmoled = isPureBlack,
paletteStyle = paletteStyle, paletteStyle = paletteStyle,
forceOpaque = true materialVersion = materialVersion,
forceOpaque = true,
customSeedColor = seedColorInt
) )
} }
}
} }
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -81,3 +82,57 @@ fun SmallTextButton(
} }
} }
} }
@Composable
fun SmallTonalTextButton(
text: String,
imageVector: ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
if (ThemeResolver.isMiuixEngine(composeEngine)) {
Card(
onClick = onClick,
modifier = modifier,
showIndication = true,
colors = CardDefaults.defaultColors(
color = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onSurfaceVariant
)
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
MiuixIcon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
} else {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
AppText(
text = text,
style = LegadoTheme.typography.labelMedium
)
}
}
}
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
@@ -70,7 +71,9 @@ fun TextCard(
AnimatedTextLine( AnimatedTextLine(
text = text, text = text,
style = textStyle, style = textStyle,
color = finalContentColor color = finalContentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
) )
} }
} }
@@ -48,6 +48,8 @@ fun <T> ListScaffold(
showSearchAction: Boolean = true, showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit, onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
onSearchSubmit: (String) -> Unit = {},
searchTrailingIcon: @Composable (() -> Unit)? = null,
searchPlaceholder: String = "搜索...", searchPlaceholder: String = "搜索...",
topBarActions: @Composable RowScope.() -> Unit = {}, topBarActions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null, bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null,
@@ -93,6 +95,8 @@ fun <T> ListScaffold(
showSearchAction = showSearchAction, showSearchAction = showSearchAction,
onSearchToggle = onSearchToggle, onSearchToggle = onSearchToggle,
onSearchQueryChange = onSearchQueryChange, onSearchQueryChange = onSearchQueryChange,
onSearchSubmit = onSearchSubmit,
searchTrailingIcon = searchTrailingIcon,
searchPlaceholder = searchPlaceholder, searchPlaceholder = searchPlaceholder,
onClearSelection = { selectionActions?.onClearSelection?.invoke() }, onClearSelection = { selectionActions?.onClearSelection?.invoke() },
topBarActions = topBarActions, topBarActions = topBarActions,
@@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
@@ -86,3 +89,68 @@ fun RoundDropdownMenu(
} }
} }
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RoundDropdownMenuLazy(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = MaterialTheme.shapes.medium,
shadowElevation: Dp = 4.dp,
verticalSpacing: Dp = 8.dp,
maxHeight: Dp = 320.dp,
content: LazyListScope.(dismiss: () -> Unit) -> Unit
) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val popupContainerColor = LegadoTheme.colorScheme.surfaceContainer
if (isMiuix) {
val popupContentColor = LegadoTheme.colorScheme.onSurface
WindowListPopup(
show = expanded,
onDismissRequest = onDismissRequest,
popupModifier = modifier
) {
ProvideAppContentColor(popupContentColor) {
ListPopupColumn {
LazyColumn(
modifier = Modifier
.background(popupContainerColor)
.heightIn(max = maxHeight),
verticalArrangement = Arrangement.spacedBy(verticalSpacing)
) {
item { Spacer(Modifier.height(12.dp)) }
content(onDismissRequest)
item { Spacer(Modifier.height(12.dp)) }
}
}
}
}
} else {
val colorScheme = rememberOpaqueColorScheme()
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = modifier,
shape = shape,
shadowElevation = shadowElevation,
containerColor = colorScheme.surfaceContainerLow
) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography(),
motionScheme = MotionScheme.expressive(),
shapes = Shapes()
) {
LazyColumn(
modifier = Modifier.heightIn(max = maxHeight),
verticalArrangement = Arrangement.spacedBy(verticalSpacing)
) {
content(onDismissRequest)
}
}
}
}
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.button.TopBarActionButton 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.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
@@ -41,6 +42,7 @@ fun <T> DynamicTopAppBar(
showSearchAction: Boolean = true, showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit, onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit, onSearchQueryChange: (String) -> Unit,
onSearchSubmit: (String) -> Unit = {},
searchPlaceholder: String, searchPlaceholder: String,
searchLeadingIcon: ImageVector = Icons.Default.Search, searchLeadingIcon: ImageVector = Icons.Default.Search,
searchTrailingIcon: @Composable (() -> Unit)? = null, searchTrailingIcon: @Composable (() -> Unit)? = null,
@@ -114,7 +116,14 @@ fun <T> DynamicTopAppBar(
SearchBar( SearchBar(
query = state.searchKey, query = state.searchKey,
onQueryChange = onSearchQueryChange, onQueryChange = onSearchQueryChange,
onSearch = onSearchSubmit,
placeholder = searchPlaceholder, placeholder = searchPlaceholder,
leadingIcon = {
AppIcon(
imageVector = searchLeadingIcon,
contentDescription = null
)
},
trailingIcon = searchTrailingIcon, trailingIcon = searchTrailingIcon,
dropdownMenu = searchDropdownMenu dropdownMenu = searchDropdownMenu
) )