From f8473077dc290c13fdda12918f22b6fe42a95135 Mon Sep 17 00:00:00 2001
From: HapeLee <63206378+HapeLee@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:08:39 +0800
Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E9=87=8D=E5=86=99?=
=?UTF-8?q?=E7=BC=93=E5=AD=98=E7=95=8C=E9=9D=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/src/main/AndroidManifest.xml | 5 -
.../main/java/io/legado/app/di/appModule.kt | 4 +
.../java/io/legado/app/help/JsExtensions.kt | 7 +-
.../java/io/legado/app/model/CacheBook.kt | 307 +++----
.../legado/app/model/webBook/SearchModel.kt | 6 +-
.../io/legado/app/service/CacheBookService.kt | 15 +-
.../legado/app/service/CheckSourceService.kt | 6 +-
.../legado/app/service/ExportBookService.kt | 30 +-
.../legado/app/ui/book/cache/CacheActivity.kt | 599 --------------
.../legado/app/ui/book/cache/CacheAdapter.kt | 50 +-
.../legado/app/ui/book/cache/CacheScreen.kt | 769 ++++++++++++++++++
.../app/ui/book/cache/CacheViewModel.kt | 337 +++++++-
.../book/changecover/ChangeCoverViewModel.kt | 4 +-
.../changesource/ChangeBookSourceViewModel.kt | 3 +-
.../app/ui/book/manga/ReadMangaViewModel.kt | 5 +-
.../app/ui/book/read/ReadBookViewModel.kt | 3 +-
.../legado/app/ui/book/search/SearchScreen.kt | 23 +-
.../app/ui/config/cacheConfig/CacheConfig.kt | 36 +
.../io/legado/app/ui/main/MainActivity.kt | 152 +++-
.../java/io/legado/app/ui/main/MainScreen.kt | 9 +-
.../legado/app/ui/theme/OpaqueColorScheme.kt | 44 +-
.../components/button/SmallTextButton.kt | 55 ++
.../app/ui/widget/components/card/TextCard.kt | 5 +-
.../ui/widget/components/list/ListScaffold.kt | 4 +
.../components/menuItem/RoundDropdownMenu.kt | 68 ++
.../components/topbar/DynamicTopAppBar.kt | 9 +
26 files changed, 1676 insertions(+), 879 deletions(-)
delete mode 100644 app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt
create mode 100644 app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 655aeb230..50232b2be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -283,11 +283,6 @@
android:name=".ui.book.bookmark.AllBookmarkActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTop" />
-
-
{ DirectLinkUploadRepository() }
single { ExploreRepositoryImpl(get()) }
@@ -122,6 +125,7 @@ val appModule = module {
viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel)
+ viewModelOf(::CacheViewModel)
viewModel { (route: ReplaceEditRoute) ->
ReplaceEditViewModel(
diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt
index de6f2eea0..502d57d99 100644
--- a/app/src/main/java/io/legado/app/help/JsExtensions.kt
+++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt
@@ -29,6 +29,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.QueryTTF
import io.legado.app.ui.association.OnLineImportActivity
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.ChineseUtils
import io.legado.app.utils.EncoderUtils
@@ -130,7 +131,7 @@ interface JsExtensions : JsEncodeUtils {
}
fun ajaxAll(urlList: Array, skipRateLimit: Boolean): Array {
return runBlocking(context) {
- urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
+ urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
url,
source = getSource(),
@@ -153,7 +154,7 @@ interface JsExtensions : JsEncodeUtils {
skipRateLimit: Boolean
): Array {
return runBlocking(context) {
- urlList.asFlow().mapAsync(AppConfig.threadCount) { url ->
+ urlList.asFlow().mapAsync(OtherConfig.threadCount) { url ->
val analyzeUrl = AnalyzeUrl(
url,
source = getSource(),
@@ -1211,4 +1212,4 @@ interface JsExtensions : JsEncodeUtils {
return OldThemeConfig.getDurConfig(appCtx).toMap()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt
index ade4f55f9..6f236aadd 100644
--- a/app/src/main/java/io/legado/app/model/CacheBook.kt
+++ b/app/src/main/java/io/legado/app/model/CacheBook.kt
@@ -11,11 +11,11 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.exception.ConcurrentException
import io.legado.app.help.book.BookHelp
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.Coroutine
import io.legado.app.model.webBook.WebBook
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.postEvent
import io.legado.app.utils.startService
@@ -44,79 +44,111 @@ import kotlin.coroutines.CoroutineContext
object CacheBook {
+ private data class QueueStats(
+ val waitingCount: Int,
+ val downloadingCount: Int
+ )
+
+ private class CacheBookCoordinator {
+ val taskMap = ConcurrentHashMap()
+ 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(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
private val _downloadSummaryFlow = MutableStateFlow("")
-
val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _downloadingIndicesFlow =
MutableStateFlow>>("" to emptySet())
-
val downloadingIndicesFlow = _downloadingIndicesFlow.asStateFlow()
private val _downloadErrorFlow =
MutableStateFlow>>("" to emptySet())
-
val downloadErrorFlow = _downloadErrorFlow.asStateFlow()
+ val successDownloadSet = ConcurrentHashMap.newKeySet()
+ val errorDownloadMap = ConcurrentHashMap()
+ private val errorIndexMap = ConcurrentHashMap>()
+
+ val cacheBookMap: ConcurrentHashMap
+ 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() {
_downloadSummaryFlow.value = downloadSummary
}
- val cacheBookMap = ConcurrentHashMap()
- 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
fun getOrCreate(bookUrl: String): CacheBookModel? {
val book = appDb.bookDao.getBook(bookUrl) ?: return null
- val bookSource = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
- updateBookSource(bookSource)
- var cacheBook = cacheBookMap[bookUrl]
- if (cacheBook != null) {
- //存在时更新,书源可能会变化,必须更新
- cacheBook.bookSource = bookSource
- cacheBook.book = book
- return cacheBook
- }
- cacheBook = CacheBookModel(bookSource, book)
- cacheBookMap[bookUrl] = cacheBook
- return cacheBook
+ val source = appDb.bookSourceDao.getBookSource(book.origin) ?: return null
+ return getOrCreate(source, book)
}
@Synchronized
fun getOrCreate(bookSource: BookSource, book: Book): CacheBookModel {
updateBookSource(bookSource)
- var cacheBook = cacheBookMap[book.bookUrl]
- if (cacheBook != null) {
- //存在时更新,书源可能会变化,必须更新
- cacheBook.bookSource = bookSource
- cacheBook.book = book
- return cacheBook
+ cacheBookMap[book.bookUrl]?.let { model ->
+ model.bookSource = bookSource
+ model.book = book
+ return model
}
- cacheBook = CacheBookModel(bookSource, book)
- cacheBookMap[book.bookUrl] = cacheBook
- return cacheBook
+ val model = CacheBookModel(bookSource, book)
+ cacheBookMap[book.bookUrl] = model
+ updateSummary()
+ return model
}
private fun updateBookSource(newBookSource: BookSource) {
- cacheBookMap.forEach {
- val model = it.value
+ cacheBookMap.forEach { (_, model) ->
if (model.bookSource.bookSourceUrl == newBookSource.bookSourceUrl) {
model.bookSource = newBookSource
}
@@ -149,100 +181,60 @@ object CacheBook {
}
fun close() {
- cacheBookMap.forEach { it.value.stop() }
+ cacheBookMap.forEach { (_, model) -> model.stop() }
cacheBookMap.clear()
successDownloadSet.clear()
errorDownloadMap.clear()
+ errorIndexMap.clear()
+ updateSummary()
}
fun setWorkingState(value: Boolean) {
- workingState.value = value
+ coordinator.setWorkingState(value)
}
- suspend fun startProcessJob(context: CoroutineContext) = mutex.withLock {
- setWorkingState(true)
- flow {
- while (currentCoroutineContext().isActive && cacheBookMap.isNotEmpty()) {
- var emitted = false
-
- cacheBookMap.forEach { (_, model) ->
- if (!model.isLoading()) {
- emit(model)
- emitted = true
- }
- workingState.first { it }
- }
-
- if (!emitted) {
- delay(1000)
- }
- }
- }.onStart {
- postEvent(EventBus.UP_DOWNLOAD_STATE, "")
- updateSummary()
- }.onEachParallel(AppConfig.threadCount) {
- coroutineScope {
- it.download(this, context)
- }
- }.onCompletion {
- postEvent(EventBus.UP_DOWNLOAD_STATE, "")
- updateSummary()
- }.collect()
+ suspend fun startProcessJob(context: CoroutineContext) {
+ coordinator.startProcessJob(context)
}
+ val totalCount: Int
+ get() {
+ val stats = collectQueueStats()
+ return stats.waitingCount + stats.downloadingCount + successDownloadSet.size + errorDownloadMap.size
+ }
+
+ val completedCount: Int
+ get() = successDownloadSet.size + errorDownloadMap.size
+
val downloadSummary: String
get() {
- return "正在下载:${onDownloadCount}|等待中:${waitCount}|失败:${errorDownloadMap.count()}|成功:${successDownloadSet.size}"
+ val stats = collectQueueStats()
+ return "正在下载:${stats.downloadingCount}|等待中:${stats.waitingCount}|失败:${errorDownloadMap.size}|成功:${successDownloadSet.size}"
}
val isRun: Boolean
get() {
- cacheBookMap.forEach {
- if (it.value.isRun()) {
- return true
- }
+ cacheBookMap.forEach { (_, model) ->
+ if (model.isRun()) return true
}
return false
}
- private val waitCount: Int
- get() {
- var count = 0
- cacheBookMap.forEach {
- count += it.value.waitCount
- }
- return count
- }
+ private fun onTaskQueuesChanged(bookUrl: String) {
+ updateSummary()
+ postEvent(EventBus.UP_DOWNLOAD, bookUrl)
+ }
- val onDownloadCount: Int
- get() {
- var count = 0
- cacheBookMap.forEach {
- count += it.value.onDownloadCount
- }
- return count
- }
+ private fun onTaskRemoved(bookUrl: String) {
+ cacheBookMap.remove(bookUrl)
+ updateSummary()
+ postEvent(EventBus.UP_DOWNLOAD, bookUrl)
+ }
- val successDownloadSet = linkedSetOf()
- val errorDownloadMap = hashMapOf()
-
- 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)
- }
+ class CacheBookModel(
+ @Volatile var bookSource: BookSource,
+ @Volatile var book: Book
+ ) {
private val waitDownloadSet = linkedSetOf()
private val onDownloadSet = linkedSetOf()
@@ -251,13 +243,22 @@ object CacheBook {
private var waitingRetry = false
private var isLoading = false
- val waitCount get() = waitDownloadSet.size
- val onDownloadCount get() = onDownloadSet.size
-
init {
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 = waitDownloadSet.size to onDownloadSet.size
+
@Synchronized
fun isRun(): Boolean {
return waitDownloadSet.isNotEmpty() || onDownloadSet.isNotEmpty() || isLoading
@@ -269,13 +270,12 @@ object CacheBook {
}
@Synchronized
- fun isLoading(): Boolean {
- return isLoading
- }
+ fun isLoading(): Boolean = isLoading
@Synchronized
fun setLoading() {
isLoading = true
+ CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
@@ -284,7 +284,9 @@ object CacheBook {
tasks.clear()
isStopped = true
isLoading = false
- postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
+ onDownloadSet.clear()
+ notifyDownloadSetChanged()
+ CacheBook.onTaskQueuesChanged(book.bookUrl)
}
@Synchronized
@@ -297,7 +299,8 @@ object CacheBook {
}
cacheBookMap[book.bookUrl] = this
isLoading = false
- postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
+ notifyDownloadSetChanged()
+ CacheBook.onTaskQueuesChanged(book.bookUrl)
}
fun addDownload(index: Int) {
@@ -309,7 +312,9 @@ object CacheBook {
onDownloadSet.remove(chapter.index)
successDownloadSet.add(chapter.primaryStr())
errorDownloadMap.remove(chapter.primaryStr())
+ errorIndexMap[book.bookUrl]?.remove(chapter.index)
notifyDownloadSetChanged()
+ notifyErrorChanged()
_cacheSuccessFlow.tryEmit(chapter)
}
@@ -317,22 +322,20 @@ object CacheBook {
private fun onPreError(chapter: BookChapter, error: Throwable) {
waitingRetry = true
if (error !is ConcurrentException) {
- errorDownloadMap[chapter.primaryStr()] =
- (errorDownloadMap[chapter.primaryStr()] ?: 0) + 1
+ errorDownloadMap.merge(chapter.primaryStr(), 1) { old, inc -> old + inc }
+ errorIndexMap.getOrPut(book.bookUrl) { ConcurrentHashMap.newKeySet() }
+ .add(chapter.index)
}
onDownloadSet.remove(chapter.index)
}
@Synchronized
private fun onPostError(chapter: BookChapter, error: Throwable) {
- //重试3次
- if ((errorDownloadMap[chapter.primaryStr()] ?: 0) < 3 && !isStopped) {
+ val retryCount = errorDownloadMap[chapter.primaryStr()] ?: 0
+ if (retryCount < 3 && !isStopped) {
waitDownloadSet.add(chapter.index)
} else {
- AppLog.put(
- "下载${book.name}-${chapter.title}失败\n${error.localizedMessage}",
- error
- )
+ AppLog.put("下载${book.name}-${chapter.title}失败\n${error.localizedMessage}", error)
}
waitingRetry = false
}
@@ -354,12 +357,13 @@ object CacheBook {
@Synchronized
private fun onFinally() {
+ val bookUrl = book.bookUrl
if (waitDownloadSet.isEmpty() && onDownloadSet.isEmpty()) {
- cacheBookMap.remove(book.bookUrl)
+ CacheBook.onTaskRemoved(bookUrl)
+ } else {
+ CacheBook.onTaskQueuesChanged(bookUrl)
}
- postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
notifyDownloadSetChanged()
- updateSummary()
}
/**
@@ -370,7 +374,7 @@ object CacheBook {
val chapterIndex = waitDownloadSet.firstOrNull()
if (chapterIndex == null) {
if (!isLoading && onDownloadSet.isEmpty()) {
- cacheBookMap.remove(book.bookUrl)
+ CacheBook.onTaskRemoved(book.bookUrl)
}
return
}
@@ -378,12 +382,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex)
return
}
- val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: let {
+ val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?: run {
waitDownloadSet.remove(chapterIndex)
return
}
if (chapter.isVolume) {
- /** 修正下载计数 */
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
_cacheSuccessFlow.tryEmit(chapter)
waitDownloadSet.remove(chapterIndex)
@@ -393,9 +396,11 @@ object CacheBook {
waitDownloadSet.remove(chapterIndex)
return
}
+
waitDownloadSet.remove(chapterIndex)
onDownloadSet.add(chapterIndex)
notifyDownloadSetChanged()
+
if (BookHelp.hasContent(book, chapter)) {
Coroutine.async(scope, context, executeContext = context) {
BookHelp.getContent(book, chapter)?.let {
@@ -405,7 +410,6 @@ object CacheBook {
onSuccess(chapter)
}.onError {
onPreError(chapter, it)
- //出现错误等待一秒后重新加入待下载列表
delay(1000)
onPostError(chapter, it)
}.onCancel {
@@ -415,6 +419,7 @@ object CacheBook {
}
return
}
+
WebBook.getContent(
scope,
bookSource,
@@ -428,7 +433,6 @@ object CacheBook {
downloadFinish(chapter, content)
}.onError {
onPreError(chapter, it)
- //出现错误等待一秒后重新加入待下载列表
delay(1000)
onPostError(chapter, it)
downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}")
@@ -445,6 +449,7 @@ object CacheBook {
synchronized(this) {
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
+ notifyDownloadSetChanged()
}
try {
val content = WebBook.getContentAwait(bookSource, book, chapter)
@@ -453,15 +458,13 @@ object CacheBook {
ReadBook.downloadFailChapters.remove(chapter.index)
return content
} catch (e: Exception) {
- if (e is CancellationException) {
- onCancel(chapter.index)
- }
+ if (e is CancellationException) onCancel(chapter.index)
onError(chapter, e)
ReadBook.downloadFailChapters[chapter.index] =
(ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
return "获取正文失败\n${e.localizedMessage}"
} finally {
- postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
+ CacheBook.onTaskQueuesChanged(book.bookUrl)
}
}
@@ -472,11 +475,11 @@ object CacheBook {
semaphore: Semaphore?,
resetPageOffset: Boolean = false
) {
- if (onDownloadSet.contains(chapter.index)) {
- return
- }
+ if (onDownloadSet.contains(chapter.index)) return
onDownloadSet.add(chapter.index)
waitDownloadSet.remove(chapter.index)
+ notifyDownloadSetChanged()
+
WebBook.getContent(
scope,
bookSource,
@@ -499,7 +502,7 @@ object CacheBook {
onCancel(chapter.index)
downloadFinish(chapter, "download canceled", resetPageOffset, true)
}.onFinally {
- postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
+ CacheBook.onTaskQueuesChanged(book.bookUrl)
}.start()
}
@@ -511,13 +514,13 @@ object CacheBook {
) {
if (ReadBook.book?.bookUrl == book.bookUrl) {
ReadBook.contentLoadFinish(
- book, chapter, content,
+ book = book,
+ chapter = chapter,
+ content = content,
resetPageOffset = resetPageOffset,
canceled = canceled
)
}
}
-
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt
index 09ab427af..eb80de3d0 100644
--- a/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt
+++ b/app/src/main/java/io/legado/app/model/webBook/SearchModel.kt
@@ -7,7 +7,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
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.utils.getPrefBoolean
import io.legado.app.utils.mapParallelSafe
@@ -33,7 +33,7 @@ import kotlin.coroutines.coroutineContext
import kotlin.math.min
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 mSearchId = 0L
private var searchPage = 1
@@ -210,4 +210,4 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
fun onSearchCancel(exception: Throwable? = null)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/service/CacheBookService.kt b/app/src/main/java/io/legado/app/service/CacheBookService.kt
index e940d10a7..f100947a6 100644
--- a/app/src/main/java/io/legado/app/service/CacheBookService.kt
+++ b/app/src/main/java/io/legado/app/service/CacheBookService.kt
@@ -12,10 +12,10 @@ import io.legado.app.constant.IntentAction
import io.legado.app.constant.NotificationId
import io.legado.app.data.appDb
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.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.postEvent
import io.legado.app.utils.servicePendingIntent
@@ -41,7 +41,7 @@ class CacheBookService : BaseService() {
private set
}
- private val threadCount = AppConfig.threadCount
+ private val threadCount = OtherConfig.threadCount
private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null
@@ -53,7 +53,12 @@ class CacheBookService : BaseService() {
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentTitle(getString(R.string.offline_cache))
- .setContentIntent(activityPendingIntent("cacheActivity"))
+ .setContentIntent(
+ activityPendingIntent(
+ MainActivity.createCacheIntent(this),
+ "cacheActivity"
+ )
+ )
builder.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
@@ -223,4 +228,4 @@ class CacheBookService : BaseService() {
startForeground(NotificationId.CacheBookService, notification)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/service/CheckSourceService.kt b/app/src/main/java/io/legado/app/service/CheckSourceService.kt
index 4d08b1d6d..c6bea9e67 100644
--- a/app/src/main/java/io/legado/app/service/CheckSourceService.kt
+++ b/app/src/main/java/io/legado/app/service/CheckSourceService.kt
@@ -18,12 +18,12 @@ import io.legado.app.exception.ContentEmptyException
import io.legado.app.exception.NoStackTraceException
import io.legado.app.exception.TocEmptyException
import io.legado.app.help.IntentData
-import io.legado.app.help.config.AppConfig
import io.legado.app.help.source.exploreKinds
import io.legado.app.model.CheckSource
import io.legado.app.model.Debug
import io.legado.app.model.webBook.WebBook
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.onEachParallel
import io.legado.app.utils.postEvent
@@ -51,7 +51,7 @@ import kotlin.math.min
* 校验书源
*/
class CheckSourceService : BaseService() {
- private var threadCount = AppConfig.threadCount
+ private var threadCount = OtherConfig.threadCount
private var searchCoroutine =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var notificationMsg = appCtx.getString(R.string.service_starting)
@@ -258,4 +258,4 @@ class CheckSourceService : BaseService() {
startForeground(NotificationId.CheckSourceService, notificationBuilder.build())
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/service/ExportBookService.kt b/app/src/main/java/io/legado/app/service/ExportBookService.kt
index c94873036..fde49a920 100644
--- a/app/src/main/java/io/legado/app/service/ExportBookService.kt
+++ b/app/src/main/java/io/legado/app/service/ExportBookService.kt
@@ -25,7 +25,7 @@ import io.legado.app.help.book.isLocalModified
import io.legado.app.help.config.AppConfig
import io.legado.app.model.ReadBook
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.FileUtils
import io.legado.app.utils.HtmlFormatter
@@ -48,6 +48,8 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
@@ -79,6 +81,8 @@ class ExportBookService : BaseService() {
companion object {
val exportProgress = ConcurrentHashMap()
val exportMsg = ConcurrentHashMap()
+ private val _exportBookUpdateFlow = MutableSharedFlow(extraBufferCapacity = 64)
+ val exportBookUpdateFlow = _exportBookUpdateFlow.asSharedFlow()
}
data class ExportConfig(
@@ -107,7 +111,7 @@ class ExportBookService : BaseService() {
)
waitExportBooks[bookUrl] = exportConfig
exportMsg[bookUrl] = getString(R.string.export_wait)
- postEvent(EventBus.EXPORT_BOOK, bookUrl)
+ notifyExportBookChanged(bookUrl)
export()
}
}.onFailure {
@@ -127,7 +131,7 @@ class ExportBookService : BaseService() {
exportProgress.clear()
exportMsg.clear()
waitExportBooks.keys.forEach {
- postEvent(EventBus.EXPORT_BOOK, it)
+ notifyExportBookChanged(it)
}
}
@@ -146,7 +150,12 @@ class ExportBookService : BaseService() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export)
.setSubText(getString(R.string.export_book))
- .setContentIntent(activityPendingIntent("cacheActivity"))
+ .setContentIntent(
+ activityPendingIntent(
+ MainActivity.createCacheIntent(this),
+ "cacheActivity"
+ )
+ )
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentText(notificationContentText)
.setDeleteIntent(servicePendingIntent(IntentAction.stop))
@@ -206,7 +215,7 @@ class ExportBookService : BaseService() {
AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
} finally {
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) {
exportMsg.remove(book.bookUrl)
- postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ notifyExportBookChanged(book.bookUrl)
val fileDoc = FileDoc.fromDir(path)
exportTxt(fileDoc, book)
}
@@ -296,13 +305,18 @@ class ExportBookService : BaseService() {
}.mapAsync(threads) { chapter ->
getExportData(book, chapter, contentProcessor, useReplace)
}.collectIndexed { index, result ->
- postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+ notifyExportBookChanged(book.bookUrl)
exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second)
}
}
+ private fun notifyExportBookChanged(bookUrl: String) {
+ postEvent(EventBus.EXPORT_BOOK, bookUrl)
+ _exportBookUpdateFlow.tryEmit(bookUrl)
+ }
+
private fun getExportData(
book: Book,
chapter: BookChapter,
@@ -875,4 +889,4 @@ class ExportBookService : BaseService() {
return result
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
deleted file mode 100644
index 3e2b4cfc1..000000000
--- a/app/src/main/java/io/legado/app/ui/book/cache/CacheActivity.kt
+++ /dev/null
@@ -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(),
- PopupMenu.OnMenuItemClickListener,
- CacheAdapter.CallBack {
-
- override val binding by viewBinding(ActivityCacheBookBinding::inflate)
- override val viewModel by viewModels()
-
- 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 = 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()
- 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(EventBus.EXPORT_BOOK) {
- notifyItemChanged(it)
- }
- observeEvent(EventBus.UP_DOWNLOAD) {
- notifyItemChanged(it)
- }
- observeEvent(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>(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 {
- 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>()
- 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 {
- 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 {
- 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>
- get() = viewModel.cacheChapters
-
- override fun exportProgress(bookUrl: String): Int? {
- return ExportBookService.exportProgress[bookUrl]
- }
-
- override fun exportMsg(bookUrl: String): String? {
- return ExportBookService.exportMsg[bookUrl]
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt
index 8d274ea8f..f235a51a6 100644
--- a/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt
+++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheAdapter.kt
@@ -1,8 +1,6 @@
package io.legado.app.ui.book.cache
import android.content.Context
-import android.os.Handler
-import android.os.Looper
import android.view.ViewGroup
import android.widget.ProgressBar
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.databinding.ItemDownloadBinding
import io.legado.app.help.book.isLocal
-import io.legado.app.model.CacheBook
import io.legado.app.utils.gone
import io.legado.app.utils.visible
@@ -72,8 +69,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
} else {
val cacheSize = callBack.cacheChapters[item.bookUrl]?.size ?: 0
tvDownload.text = context.getString(R.string.download_count, cacheSize, item.totalChapterNum)
- val cacheTask = CacheBook.cacheBookMap[item.bookUrl]
- if (cacheTask != null && !cacheTask.isStop()) {
+ if (callBack.isBookDownloading(item.bookUrl)) {
val progress = if (item.totalChapterNum > 0) cacheSize * 100 / item.totalChapterNum else 0
progressDownload.progress = progress
progressDownload.visible()
@@ -90,17 +86,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
binding.run {
ivDownload.setOnClickListener {
getItem(holder.layoutPosition)?.let { book ->
- CacheBook.cacheBookMap[book.bookUrl]?.let {
- 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)
- }
+ callBack.toggleBookDownload(book)
}
}
tvExport.setOnClickListener {
@@ -108,18 +94,7 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
}
ivDelete.setOnClickListener {
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)
- }, 10)
- } else {
- callBack.deleteDownload(book)
- }
- } ?: run {
- callBack.deleteDownload(book)
- }
+ callBack.deleteDownload(book)
}
}
@@ -131,15 +106,12 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
button.gone()
} else {
button.visible()
- CacheBook.cacheBookMap[book.bookUrl]?.let {
- if (!it.isStop()) {
- button.icon = ContextCompat.getDrawable(context, R.drawable.ic_stop_black_24dp)
- } else {
- button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play)
- }
- } ?: run {
- button.icon = ContextCompat.getDrawable(context, R.drawable.ic_play)
+ val iconRes = if (callBack.isBookDownloading(book.bookUrl)) {
+ R.drawable.ic_stop_black_24dp
+ } else {
+ R.drawable.ic_play
}
+ button.icon = ContextCompat.getDrawable(context, iconRes)
}
}
@@ -162,11 +134,13 @@ class CacheAdapter(context: Context, private val callBack: CallBack) :
}
interface CallBack {
- val cacheChapters: HashMap>
+ val cacheChapters: Map>
+ fun isBookDownloading(bookUrl: String): Boolean
+ fun toggleBookDownload(book: Book)
fun export(position: Int)
fun exportProgress(bookUrl: String): Int?
fun exportMsg(bookUrl: String): String?
fun deleteDownload(book: Book)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt
new file mode 100644
index 000000000..9b0b8d7fd
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheScreen.kt
@@ -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(null) }
+ var pendingExportAll by remember { mutableStateOf(false) }
+ var customExportPath by remember { mutableStateOf("") }
+ var customExportBook by remember { mutableStateOf(null) }
+ var customExportAllChapter by remember { mutableStateOf(false) }
+ var customEpubScopeInput by remember { mutableStateOf("") }
+ var customEpubScopeError by remember { mutableStateOf(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>()
+ 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 {
+ 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 {
+ 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 {
+ action = IntentAction.start
+ putExtra("bookUrl", book.bookUrl)
+ putExtra("exportType", exportType)
+ putExtra("exportPath", path)
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
index f2e26727e..d1a3d8af5 100644
--- a/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/cache/CacheViewModel.kt
@@ -1,32 +1,299 @@
package io.legado.app.ui.book.cache
import android.app.Application
-import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
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.BookChapter
+import io.legado.app.data.entities.BookGroup
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.coroutine.Coroutine
-import io.legado.app.utils.sendValue
+import io.legado.app.model.CacheBook
+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.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) {
- val upAdapterLiveData = MutableLiveData()
+data class CacheUiState(
+ val groupId: Long = -1,
+ val groupName: String? = null,
+ val groupList: List = emptyList(),
+ val books: List = emptyList(),
+ val isDownloadRunning: Boolean = false,
+ val cacheVersion: Long = 0,
+ val exportConfig: CacheExportConfig = CacheExportConfig()
+)
- private var loadChapterCoroutine: Coroutine? = null
- val cacheChapters = hashMapOf>()
+sealed interface CacheIntent {
+ data class Initialize(val groupId: Long) : CacheIntent
+ data class ChangeGroup(val groupId: Long) : CacheIntent
+ data class StartDownloadForVisibleBooks(
+ val books: List,
+ 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) {
- loadChapterCoroutine?.cancel()
- loadChapterCoroutine = execute {
+sealed interface CacheEffect {
+ data class NotifyBookChanged(val bookUrl: String) : CacheEffect
+ 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(extraBufferCapacity = 32)
+ val effects = _effects.asSharedFlow()
+
+ private val cacheChapters = hashMapOf>()
+ 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? = cacheChapters[bookUrl]
+
+ fun getAllCacheChapters(): Map> = 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) {
+ execute {
books.forEach { book ->
if (!book.isLocal && !cacheChapters.contains(book.bookUrl)) {
val chapterCaches = hashSetOf()
val cacheNames = BookHelp.getChapterFiles(book)
if (cacheNames.isNotEmpty()) {
- appDb.bookChapterDao.getChapterList(book.bookUrl).also {
+ bookChapterDao.getChapterList(book.bookUrl).also {
book.totalChapterNum = it.size
}.forEach { chapter ->
if (cacheNames.contains(chapter.getFileName()) || chapter.isVolume) {
@@ -35,17 +302,51 @@ class CacheViewModel(application: Application) : BaseViewModel(application) {
}
}
cacheChapters[book.bookUrl] = chapterCaches
- upAdapterLiveData.sendValue(book.bookUrl)
+ emitBookChanged(book.bookUrl)
}
ensureActive()
}
}
}
- fun clearCacheForBook(book: Book) {
- BookHelp.clearCache(book)
- cacheChapters[book.bookUrl] = hashSetOf()
- upAdapterLiveData.sendValue(book.bookUrl)
+ private fun onChapterCached(chapter: BookChapter) {
+ val bookUrl = chapter.bookUrl
+ val chapterSet = cacheChapters.getOrPut(bookUrl) { hashSetOf() }
+ chapterSet.add(chapter.url)
+ emitBookChanged(bookUrl)
}
-}
\ No newline at end of file
+ private fun startDownloadForVisibleBooks(books: List, 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)
+ cacheChapters[book.bookUrl] = hashSetOf()
+ emitBookChanged(book.bookUrl)
+ }
+
+ private fun emitBookChanged(bookUrl: String) {
+ _uiState.update { it.copy(cacheVersion = it.cacheVersion + 1) }
+ _effects.tryEmit(CacheEffect.NotifyBookChanged(bookUrl))
+ }
+
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
index f99ac2406..74075ed78 100644
--- a/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/changecover/ChangeCoverViewModel.kt
@@ -12,8 +12,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
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.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.mapParallelSafe
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExecutorCoroutineDispatcher
@@ -36,7 +36,7 @@ import java.util.concurrent.Executors
import kotlin.math.min
class ChangeCoverViewModel(application: Application) : BaseViewModel(application) {
- private val threadCount = AppConfig.threadCount
+ private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
private var searchSuccess: ((SearchBook) -> Unit)? = null
private var upAdapter: (() -> Unit)? = null
diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
index 2f8a37956..c101be7be 100644
--- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
@@ -25,6 +25,7 @@ import io.legado.app.help.config.SourceConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.SourceHelp
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.mapParallel
import io.legado.app.utils.mapParallelSafe
@@ -59,7 +60,7 @@ import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(application) {
- private val threadCount = AppConfig.threadCount
+ private val threadCount = OtherConfig.threadCount
private var searchPool: ExecutorCoroutineDispatcher? = null
val searchStateData = MutableLiveData()
private val _isSearching = MutableStateFlow(false)
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
index cccdedd7d..d35acce89 100644
--- a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
@@ -28,6 +28,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.model.ReadManga
import io.legado.app.model.localBook.LocalBook
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.mapParallelSafe
import io.legado.app.utils.postEvent
@@ -201,7 +202,7 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
}.onStart {
// 自动换源
- }.mapParallelSafe(AppConfig.threadCount) { source ->
+ }.mapParallelSafe(OtherConfig.threadCount) { source ->
val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
@@ -395,4 +396,4 @@ class ReadMangaViewModel(application: Application) : BaseViewModel(application)
context.toastOnUi("添加书籍失败")
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
index 00a3aab5c..a3e5911d5 100644
--- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
@@ -31,6 +31,7 @@ import io.legado.app.model.webBook.WebBook
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.page.entities.TextChapter
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.mapParallelSafe
import io.legado.app.utils.postEvent
@@ -307,7 +308,7 @@ class ReadBookViewModel(application: Application) : BaseViewModel(application) {
}
}.onStart {
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()
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
index 096c10c99..3ed0dc4e8 100644
--- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
+++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
@@ -242,20 +242,15 @@ fun SearchScreen(
onSearch = submitSearch,
placeholder = searchLabel,
trailingIcon = {
- Row(
- modifier = Modifier.padding(end = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (queryInput.isNotEmpty()) {
- TopBarActionButton(
- onClick = {
- queryInput = ""
- viewModel.onIntent(SearchIntent.UpdateQuery(""))
- },
- imageVector = AppIcons.Close,
- contentDescription = stringResource(R.string.clear)
- )
- }
+ if (queryInput.isNotEmpty()) {
+ TopBarActionButton(
+ onClick = {
+ queryInput = ""
+ viewModel.onIntent(SearchIntent.UpdateQuery(""))
+ },
+ imageVector = AppIcons.Close,
+ contentDescription = stringResource(R.string.clear)
+ )
}
},
)
diff --git a/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt b/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt
new file mode 100644
index 000000000..121cdba97
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/config/cacheConfig/CacheConfig.kt
@@ -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(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
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt
index d112bd6fd..b28f75885 100644
--- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt
@@ -13,9 +13,13 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.navigation3.runtime.NavKey
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.ui.about.CrashLogsDialog
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.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.config.ConfigNavScreen
import io.legado.app.ui.config.ConfigTag
@@ -62,6 +72,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
+import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -82,8 +93,13 @@ open class MainActivity : BaseComposeActivity() {
private const val ROUTE_SETTINGS_BACKUP = "settings/backup"
private const val ROUTE_IMPORT_LOCAL = "import/local"
private const val ROUTE_IMPORT_REMOTE = "import/remote"
+ private const val ROUTE_CACHE = "cache"
+ private const val ROUTE_SEARCH = "search"
private const val ROUTE_RSS_SORT = "rss/sort"
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_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 {
return when (configTag) {
ConfigTag.OTHER_CONFIG -> ROUTE_SETTINGS_OTHER
@@ -191,6 +229,15 @@ open class MainActivity : BaseComposeActivity() {
@Serializable
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?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -332,12 +379,23 @@ open class MainActivity : BaseComposeActivity() {
onOpenSettings = {
navigateToRoute(backStack, MainRouteSettings)
},
+ onNavigateToSearch = { key ->
+ navigateToRoute(
+ backStack,
+ MainRouteSearch(
+ key = key?.trim()?.takeIf { it.isNotEmpty() }
+ )
+ )
+ },
onNavigateToRemoteImport = {
navigateToRoute(backStack, MainRouteImportRemote)
},
onNavigateToLocalImport = {
navigateToRoute(backStack, MainRouteImportLocal)
},
+ onNavigateToCache = { groupId ->
+ navigateToRoute(backStack, MainRouteCache(groupId))
+ },
onNavigateToRssSort = { sourceUrl, sortUrl, key ->
navigateToRoute(
backStack,
@@ -405,6 +463,64 @@ open class MainActivity : BaseComposeActivity() {
)
}
+ entry { route ->
+ CacheRouteScreen(
+ groupId = route.groupId,
+ onBackClick = { navigateBack(backStack) }
+ )
+ }
+
+ entry { route ->
+ val searchViewModel = koinViewModel()
+ 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 {
+ putExtra("name", name)
+ putExtra("author", author)
+ putExtra("bookUrl", bookUrl)
+ }
+ },
+ onOpenSourceManage = {
+ this@MainActivity.startActivity()
+ }
+ )
+ }
+
entry { route ->
RssSortRouteScreen(
sourceUrl = route.sourceUrl,
@@ -469,7 +585,18 @@ open class MainActivity : BaseComposeActivity() {
}
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) {
backStack.add(route)
} else {
@@ -660,15 +787,20 @@ open class MainActivity : BaseComposeActivity() {
private fun resolveStartRoute(route: String?): MainRoute {
return when (route) {
- "main" -> MainRouteHome
- "settings" -> MainRouteSettings
- "settings/other" -> MainRouteSettingsOther
- "settings/read" -> MainRouteSettingsRead
- "settings/cover" -> MainRouteSettingsCover
- "settings/theme" -> MainRouteSettingsTheme
- "settings/backup" -> MainRouteSettingsBackup
- "import/local" -> MainRouteImportLocal
- "import/remote" -> MainRouteImportRemote
+ ROUTE_MAIN -> MainRouteHome
+ ROUTE_SETTINGS -> MainRouteSettings
+ ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther
+ ROUTE_SETTINGS_READ -> MainRouteSettingsRead
+ ROUTE_SETTINGS_COVER -> MainRouteSettingsCover
+ ROUTE_SETTINGS_THEME -> MainRouteSettingsTheme
+ ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
+ ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
+ 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
}
}
diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt
index 3e8697bbf..fe7bae282 100644
--- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt
+++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt
@@ -57,7 +57,6 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
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.main.bookshelf.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -90,8 +89,10 @@ fun MainScreen(
viewModel: MainViewModel = koinViewModel(),
useRail: Boolean,
onOpenSettings: () -> Unit,
+ onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit,
+ onNavigateToCache: (Long) -> Unit,
onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) {
@@ -174,7 +175,7 @@ fun MainScreen(
ExtendedFloatingActionButton(
modifier = Modifier.padding(start = 20.dp),
- onClick = { context.startActivity() },
+ onClick = { onNavigateToSearch(null) },
expanded = expanded,
icon = { Icon(Icons.Default.Search, contentDescription = null) },
text = { AppText(stringResource(R.string.search)) }
@@ -377,8 +378,10 @@ fun MainScreen(
putExtra("bookUrl", book.bookUrl)
}
},
+ onNavigateToSearch = { query -> onNavigateToSearch(query) },
onNavigateToRemoteImport = onNavigateToRemoteImport,
- onNavigateToLocalImport = onNavigateToLocalImport
+ onNavigateToLocalImport = onNavigateToLocalImport,
+ onNavigateToCache = onNavigateToCache
)
MainDestination.Explore -> ExploreScreen()
diff --git a/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt b/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt
index 883dbc880..34278737f 100644
--- a/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt
+++ b/app/src/main/java/io/legado/app/ui/theme/OpaqueColorScheme.kt
@@ -1,9 +1,10 @@
package io.legado.app.ui.theme
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@@ -11,20 +12,41 @@ import io.legado.app.ui.theme.ThemeEngine.getColorScheme
@Composable
fun rememberOpaqueColorScheme(): ColorScheme {
val context = LocalContext.current
- val isDark = isSystemInDarkTheme()
+ val currentTheme = LocalLegadoThemeColors.current
val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme)
+ val isDark = currentTheme.isDark
val isPureBlack = ThemeConfig.isPureBlack
val hasImageBg = ThemeConfig.hasImageBg(isDark)
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) {
- getColorScheme(
- context = context,
- mode = appThemeMode,
- darkTheme = isDark,
- isAmoled = isPureBlack,
- paletteStyle = paletteStyle,
- forceOpaque = true
- )
+ return remember(
+ context,
+ currentTheme.colorScheme,
+ appThemeMode,
+ isDark,
+ isPureBlack,
+ hasImageBg,
+ paletteStyle,
+ materialVersion,
+ seedColorInt
+ ) {
+ if (appThemeMode != AppThemeMode.Transparent) {
+ currentTheme.colorScheme
+ } else {
+ getColorScheme(
+ context = context,
+ mode = appThemeMode,
+ darkTheme = isDark,
+ isAmoled = isPureBlack,
+ paletteStyle = paletteStyle,
+ materialVersion = materialVersion,
+ forceOpaque = true,
+ customSeedColor = seedColorInt
+ )
+ }
}
}
diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
index 19d9399ef..a67fd5221 100644
--- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
index 7b44b6c74..5f20daa6c 100644
--- a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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 io.legado.app.ui.theme.LegadoTheme
@@ -70,7 +71,9 @@ fun TextCard(
AnimatedTextLine(
text = text,
style = textStyle,
- color = finalContentColor
+ color = finalContentColor,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
)
}
}
diff --git a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt
index bc682832b..46d95f15e 100644
--- a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt
@@ -48,6 +48,8 @@ fun ListScaffold(
showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit,
+ onSearchSubmit: (String) -> Unit = {},
+ searchTrailingIcon: @Composable (() -> Unit)? = null,
searchPlaceholder: String = "搜索...",
topBarActions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable (ColumnScope.(GlassTopAppBarScrollBehavior) -> Unit)? = null,
@@ -93,6 +95,8 @@ fun ListScaffold(
showSearchAction = showSearchAction,
onSearchToggle = onSearchToggle,
onSearchQueryChange = onSearchQueryChange,
+ onSearchSubmit = onSearchSubmit,
+ searchTrailingIcon = searchTrailingIcon,
searchPlaceholder = searchPlaceholder,
onClearSelection = { selectionActions?.onClearSelection?.invoke() },
topBarActions = topBarActions,
diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
index 234234f05..e6c46ce79 100644
--- a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
@@ -6,6 +6,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
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.ExperimentalMaterial3ExpressiveApi
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)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
index 6d1fad1b1..4fd61fe0f 100644
--- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.adaptiveHorizontalPadding
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.TopBarNavigationButton
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -41,6 +42,7 @@ fun DynamicTopAppBar(
showSearchAction: Boolean = true,
onSearchToggle: (Boolean) -> Unit,
onSearchQueryChange: (String) -> Unit,
+ onSearchSubmit: (String) -> Unit = {},
searchPlaceholder: String,
searchLeadingIcon: ImageVector = Icons.Default.Search,
searchTrailingIcon: @Composable (() -> Unit)? = null,
@@ -114,7 +116,14 @@ fun DynamicTopAppBar(
SearchBar(
query = state.searchKey,
onQueryChange = onSearchQueryChange,
+ onSearch = onSearchSubmit,
placeholder = searchPlaceholder,
+ leadingIcon = {
+ AppIcon(
+ imageVector = searchLeadingIcon,
+ contentDescription = null
+ )
+ },
trailingIcon = searchTrailingIcon,
dropdownMenu = searchDropdownMenu
)