feat(reader): 船新的基于 Compose 实现的阅读菜单界面与各项优化

* 初始版本

* refactor(reader): MVI architecture + menu settings overhaul

MVI/UDF Architecture:
- Add ReadBookContract: UiState, Intent, Effect, Sheet, Dialog, ConfigUpdate
- Refactor ReadBookViewModel with onIntent() entry point
- Add ReadBookScreen/ReadBookRouteScreen stateless composables
- Add ReadBookColorTheme for reader-specific theme override
- Add ReadConfigContract for read config screen MVI

ReadStyleSheet Decomposition:
- Split ReadStyleSheet into SystemMenuPage, TextTitleSheet,
  HeaderFooterPage, TitleBarIconSheet, GlobalThemePage
- Each page owns its tab state, color picker, and config mutations

Data Layer (DataStore-backed Repositories):
- Add ReadSettingsRepository with ReadPreferences flow
- Add MangaSettingsRepository with MangaPreferences flow
- Add ReadAloudSettingsRepository for TTS settings
- Add ReadStyleRepository (file I/O for read style configs)
- Add ReadBookStyleConfigRepository (repository boundary)
- Add ReadStyleResolver for background/theme resolution
- Remove legacy ReadTipConfig (merged into ReadBookConfig)
- Register all new repositories in Koin appModule

Menu Settings:
- Add unified border settings (width, color, colorNight) for
  both top and bottom bars with drawWithCache + drawPath
- Add ColorModePill with light/dark toggle and '+' icon for
  no-color state in TinySettingItems
- Add TinyClearColorModeSettingItem for border color config
- Add menu color customization (bg, accent, container) with
  day/night variants and seed color mode
- Add bottom bar layout settings (corner radius, margins)
- Add custom icon support for menu buttons and title bar icons
- Add title bar icon position setting

Config Pipeline:
- Consolidate all ReadBookConfig mutations into typed ConfigUpdate
  sealed interface with legacy UP_CONFIG code mapping
- Add resolvedMenuBorderColor (auto day/night switching)
- Add 20 new PreferKey constants for menu settings
- Update manga config dialogs to use MangaSettingsRepository
- Update ReadConfigViewModel with DataStore flow observation

* feat(reader): add liquid glass menu bars and floating bottom bar

- Integrate com.kyant.backdrop 2.0.0 for liquid glass blur/vibrancy
  effects on reading menu top bar and bottom bar (Android 13+)
- Add floating bottom bar toggle replacing manual margin sliders
- Extract ReadMenuConfig data class for proper MVI state management
  of all menu configuration properties
- Enable edge-to-edge in BaseComposeActivity, remove manual
  navigation bar color management across base classes and controller
- Fix fullScreen() to use setDecorFitsSystemWindows(false)

* Refactor series button rendering

* 实现液态玻璃

* 优化液态玻璃

* @
fix(reader): fix MVI behavior issues and improve sheet lifecycle

Behavior fixes:
- Fix AddSourceAsNewBook empty implementation — wire intent to addToBookshelf
- Fix content edit loading stuck on null chapter/content — use onFinally
- Fix cannot save empty content — remove isEmpty guard, use title as loaded proxy
- Fix bookmark not opening — use openReadMenuRoute instead of dead activeSheet path
- Fix chapter source sheet persists — animate dismiss via show param before removal
- Fix chapter source reopens to TOC — reset showToc in initData
- Fix SaveChapterContent saves to wrong chapter — carry chapterIndex in intent
- Fix chapter source search runs after dismiss — cancel searchJob in dispose
- Fix "go to background" button in ReadAloud — use CloseReadBook intent

Architecture improvements:
- ReadMenuConfig uses ImmutableMap for Compose stability
- Fix stale remember in ReadBookMenuBar title bar icons
- Move icon picker file IO from composable to ViewModel
- Route launcher results through MVI intents instead of direct calls
- Fix ChangeChapterSource effect collector key — use viewModel not Unit
- Make ReadAloudConfigSheet UDF-compliant — stateless with intents
- Remove main menu buttons from AutoRead and ReadAloud sheets
@

* @
refactor(reader): use Material Icons and ConfirmDismissButtonsRow

- TitleBarIconSheet: replace drawable resources with Icons library, use
  ConfirmDismissButtonsRow for save/cancel buttons
- SystemMenuPage MenuCustomIconSheet: same icon and button treatment
- ReadBookMenuBar: update ToolButtonDef/TitleBarIconDef to use ImageVector
  instead of drawable resource Int
- Use AutoMirrored variants for List and HelpOutline icons
@

* feat: 优化液态玻璃

* feat: 优化液态玻璃

* fix(AppLogSheet): 从本地文件读取日志,替代内存列表

* @
feat(reader): highlight rules, theme refactor & MVI improvements

- Add HighlightRule entity, DAO, and config/edit sheets
- Add ThemeConfigStore, remove OldThemeConfig
- Refactor ReadBookContract/ViewModel/Screen for deeper MVI
- Add CharStyle for text rendering, refactor TextLine/TextChapterLayout
- Add SectionTitle, TinySettingItems UI components
- Remove legacy XML layouts (regex_color_config)
- Update database schema to v90
- Various sheet and widget refinements
@

* @
fix: address PR review feedback

- ReadStyleRepository: fix copy-paste bug (bgStrNight→bgStr) + add bgTypeNight import
- BgImageSpan: replace Bitmap.createScaledBitmap with BitmapShader matrix scaling
- HighlightRule: use UUID.randomUUID() instead of System.currentTimeMillis()
- ChangeSourceSearchUseCase: guard against empty chapters list
@

* @fix: remove residual regexColorRules references after merge

- ChangeSourceSearchUseCase: adapt filter lambda to new 3-param signature
- TextChapterLayout: remove globalRegexResult field, preApplyRegexColorRules
  call, and the entire regexColorRules fullTextBuilder block
@

* fix: 图标间距
This commit is contained in:
Kudomaga
2026-06-07 18:28:33 +08:00
committed by GitHub
parent 116762f826
commit 4c088448c3
209 changed files with 25994 additions and 12717 deletions
+4 -4
View File
@@ -109,10 +109,10 @@
"Bash(node *)",
"Bash(Select-String -Pattern \"BUILD|error|Error\")",
"Bash(Select-Object -First 10)",
"Bash(git push *)",
"Bash(gh run *)",
"WebFetch(domain:ktor.io)",
"WebFetch(domain:api.ktor.io)"
"Bash(Select-Object -Last 10)",
"Bash(dir /s /b \"%USERPROFILE%\\\\.gradle\\\\caches\\\\*kyant*\")",
"Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")",
"PowerShell(grep *)"
]
}
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -139,22 +139,6 @@
android:enableOnBackInvokedCallback="true"
android:exported="false"
android:windowSoftInputMode="adjustPan|stateHidden" />
<!-- 阅读界面 -->
<activity
android:name=".ui.book.read.ReadBookActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="uiMode|orientation|screenSize"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
</intent-filter>
<meta-data
android:name="com.samsung.android.support.REMOTE_ACTION"
android:resource="@xml/spen_remote_actions" />
</activity>
<!-- 阅读漫画界面 -->
<activity
android:name=".ui.book.manga.ReadMangaActivity"
+9 -3
View File
@@ -45,8 +45,8 @@ import io.legado.app.help.LifecycleHelp
import io.legado.app.help.RuleBigDataHelp
import io.legado.app.help.book.BookHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.OldThemeConfig.applyDayNightInit
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ThemeConfigStore.applyDayNightInit
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.Cronet
@@ -57,6 +57,7 @@ import io.legado.app.help.source.SourceHelp
import io.legado.app.help.storage.Backup
import io.legado.app.lib.theme.primaryColor
import io.legado.app.model.BookCover
import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.utils.ChineseUtils
import io.legado.app.utils.FirebaseManager
import io.legado.app.utils.LogUtils
@@ -161,7 +162,7 @@ class App : Application(), ImageLoaderFactory {
BookHelp.clearInvalidCache()
Backup.clearCache()
ReadBookConfig.clearBgAndCache()
OldThemeConfig.clearBg()
ThemeConfigStore.clearBg()
//初始化简繁转换引擎
when (AppConfig.chineseConverterType) {
1 -> {
@@ -189,6 +190,11 @@ class App : Application(), ImageLoaderFactory {
// oldConfig = Configuration(newConfig)
// }
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
TextLine.trimCaches(level)
}
/**
* 尝试在安装了GMS的设备上(GMS或者MicroG)使用GMS内置的Conscrypt
* 作为首选JCE提供程序,而使Okhttp在低版本Android上
@@ -7,7 +7,6 @@ import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import io.legado.app.R
import io.legado.app.receiver.SharedReceiverActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.MainActivity
object ShortCuts {
@@ -34,7 +33,9 @@ object ShortCuts {
val bookShelfIntent = MainActivity.createHomeIntent(context).apply {
action = Intent.ACTION_VIEW
}
val readBookIntent = buildIntent<ReadBookActivity>(context)
val readBookIntent = MainActivity.createReadBookIntent(context).apply {
action = Intent.ACTION_VIEW
}
return ShortcutInfoCompat.Builder(context, "lastRead")
.setShortLabel(context.getString(R.string.last_read))
.setLongLabel(context.getString(R.string.last_read))
@@ -27,7 +27,7 @@ import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.constant.Theme
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.lib.theme.primaryColor
import io.legado.app.utils.applyOpenTint
import io.legado.app.utils.applyTint
@@ -36,7 +36,6 @@ import io.legado.app.utils.fullScreen
import io.legado.app.utils.getPrefString
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.observeEvent
import io.legado.app.utils.setNavigationBarColorAuto
import io.legado.app.utils.setStatusBarColorAuto
import io.legado.app.utils.themeColor
import io.legado.app.utils.toastOnUi
@@ -101,7 +100,6 @@ abstract class BaseActivity<VB : ViewBinding>(
else{
setupSystemBar()
}
window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
//setupSystemBar()
setContentView(binding.root)
upBackgroundImage()
@@ -231,7 +229,7 @@ abstract class BaseActivity<VB : ViewBinding>(
open fun upBackgroundImage() {
if (imageBg) {
try {
OldThemeConfig.getBgImage(this, windowManager.windowSize)?.let {
ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
window.decorView.background = it.toDrawable(resources)
}
} catch (e: OutOfMemoryError) {
@@ -261,4 +259,4 @@ abstract class BaseActivity<VB : ViewBinding>(
currentFocus?.hideSoftInput()
super.finish()
}
}
}
@@ -12,8 +12,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.legado.app.constant.AppLog
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.utils.setNavigationBarColorAuto
import io.legado.app.utils.themeColor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
@@ -30,7 +28,6 @@ abstract class BaseBottomSheetDialogFragment(
override fun onStart() {
super.onStart()
dialog?.window?.setNavigationBarColorAuto(requireContext().themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
val screenHeight = resources.displayMetrics.heightPixels
(dialog as? BottomSheetDialog)?.behavior?.apply {
@@ -70,4 +67,4 @@ abstract class BaseBottomSheetDialogFragment(
open fun observeLiveBus() {
}
}
}
@@ -1,7 +1,9 @@
package io.legado.app.base
import android.os.Bundle
import android.os.Build
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.core.graphics.drawable.toDrawable
@@ -9,7 +11,7 @@ import androidx.core.view.WindowCompat
import io.legado.app.constant.EventBus
import io.legado.app.constant.Theme
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.ui.theme.AppTheme
import io.legado.app.utils.disableAutoFill
import io.legado.app.utils.fullScreen
@@ -34,6 +36,10 @@ abstract class BaseComposeActivity(
AppContextWrapper.applyLocaleAndFont(this)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
setupSystemBar()
// Compose 入口
@@ -66,7 +72,7 @@ abstract class BaseComposeActivity(
open fun upBackgroundImage() {
try {
OldThemeConfig.getBgImage(this, windowManager.windowSize)?.let {
ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
window.setBackgroundDrawable(it.toDrawable(resources))
}
} catch (_: Exception) {}
@@ -103,6 +103,10 @@ object PreferKey {
const val showBrightnessView = "showBrightnessView"
const val useUnderline = "useUnderline"
const val regexColorRules = "regexColorRules"
const val highlightRuleItems = "highlightRuleItems"
const val highlightRuleDialog = "highlightRuleDialog"
const val highlightRuleBookTitle = "highlightRuleBookTitle"
const val highlightRuleBracketNote = "highlightRuleBracketNote"
const val adaptSpecialStyle = "adaptSpecialStyle"
const val autoClearExpired = "autoClearExpired"
const val autoChangeSource = "autoChangeSource"
@@ -288,6 +292,35 @@ object PreferKey {
const val isPredictiveBackEnabled = "isPredictiveBackEnabled"
const val replaceSortMode = "desc"
const val readBarStyle = "readBarStyle"
const val readMenuBgColor = "readMenuBgColor"
const val readMenuAccentColor = "readMenuAccentColor"
const val readMenuContainerColor = "readMenuContainerColor"
const val readMenuBgColorNight = "readMenuBgColorNight"
const val readMenuAccentColorNight = "readMenuAccentColorNight"
const val readMenuContainerColorNight = "readMenuContainerColorNight"
const val readMenuColorMode = "readMenuColorMode"
const val readMenuIconShowText = "readMenuIconShowText"
const val readMenuIconStyle = "readMenuIconStyle"
const val readMenuIconItemsPerRow = "readMenuIconItemsPerRow"
const val readMenuIconRowCount = "readMenuIconRowCount"
const val readMenuBottomCornerRadius = "readMenuBottomCornerRadius"
const val readMenuFloatingBottomBar = "readMenuFloatingBottomBar"
const val readMenuTopBarBlurMode = "readMenuTopBarBlurMode"
const val readMenuBottomBarBlurMode = "readMenuBottomBarBlurMode"
const val readMenuTopBarLiquidGlassButtons = "readMenuTopBarLiquidGlassButtons"
const val readMenuBottomBarLiquidGlassButtons = "readMenuBottomBarLiquidGlassButtons"
const val readMenuTopBarBlurStyle = "readMenuTopBarBlurStyle"
const val readMenuBottomBarBlurStyle = "readMenuBottomBarBlurStyle"
const val readMenuBlurRadius = "readMenuBlurRadius"
const val readMenuBlurAlpha = "readMenuBlurAlpha"
const val readMenuLensRadius = "readMenuLensRadius"
const val readMenuBorderWidth = "readMenuBorderWidth"
const val readMenuBorderColor = "readMenuBorderColor"
const val readMenuBorderColorNight = "readMenuBorderColorNight"
const val readMenuCustomIcons = "readMenuCustomIcons"
const val titleBarCustomIcons = "titleBarCustomIcons"
const val titleBarIconPosition = "titleBarIconPosition"
const val showTitleBarIcons = "showTitleBarIcons"
const val disableReturnKey = "disableReturnKey"
const val selectText = "selectText"
//我在干什么
@@ -351,3 +384,14 @@ object PreferKey {
const val eyeProtectionStartTime = "eyeProtectionStartTime"
const val eyeProtectionEndTime = "eyeProtectionEndTime"
}
object ReadMenuBlurMode {
const val None = 0
const val LiquidGlass = 1
const val Haze = 2
}
object ReadMenuBlurStyle {
const val Solid = 0
const val Progressive = 1
}
@@ -15,6 +15,7 @@ import io.legado.app.data.dao.BookmarkDao
import io.legado.app.data.dao.CacheDao
import io.legado.app.data.dao.CookieDao
import io.legado.app.data.dao.DictRuleDao
import io.legado.app.data.dao.HighlightRuleDao
import io.legado.app.data.dao.HomepageCustomSetDao
import io.legado.app.data.dao.HomepageModuleDao
import io.legado.app.data.dao.HttpTTSDao
@@ -41,6 +42,7 @@ import io.legado.app.data.entities.Cache
import io.legado.app.data.entities.Cookie
import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HomepageCustomSet
import io.legado.app.data.entities.HighlightRule
import io.legado.app.data.entities.HomepageModule
import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist
@@ -73,7 +75,7 @@ val appDb by lazy {
}
@Database(
version = 89,
version = 90,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -81,7 +83,8 @@ val appDb by lazy {
RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class,
SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class],
SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class,
HighlightRule::class],
views = [BookSourcePart::class],
autoMigrations = [
AutoMigration(from = 43, to = 44),
@@ -129,7 +132,8 @@ val appDb by lazy {
AutoMigration(from = 85, to = 86),
AutoMigration(from = 86, to = 87),
AutoMigration(from = 87, to = 88),
AutoMigration(from = 88, to = 89)
AutoMigration(from = 88, to = 89),
AutoMigration(from = 89, to = 90)
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -158,6 +162,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract val searchContentHistoryDao: SearchContentHistoryDao
abstract val homepageModuleDao: HomepageModuleDao
abstract val homepageCustomSetDao: HomepageCustomSetDao
abstract val highlightRuleDao: HighlightRuleDao
companion object {
@@ -0,0 +1,41 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.legado.app.data.entities.HighlightRule
@Dao
interface HighlightRuleDao {
@Query("SELECT * FROM highlightRules ORDER BY position ASC")
fun getAll(): List<HighlightRule>
@Query("SELECT * FROM highlightRules WHERE enabled = 1 AND pattern != '' ORDER BY position ASC")
fun getEnabled(): List<HighlightRule>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(rules: List<HighlightRule>)
@Update
fun update(rule: HighlightRule)
@Delete
fun delete(rule: HighlightRule)
@Query("DELETE FROM highlightRules")
fun deleteAll()
@Query("SELECT COUNT(*) FROM highlightRules")
fun count(): Int
@Transaction
fun replaceAll(rules: List<HighlightRule>) {
deleteAll()
insertAll(rules)
}
}
@@ -0,0 +1,94 @@
package io.legado.app.data.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(tableName = "highlightRules")
data class HighlightRule(
@PrimaryKey
var id: String = UUID.randomUUID().toString(),
var name: String = "",
var pattern: String = "",
var sampleText: String = "",
var targetScope: Int = TARGET_ALL,
var enabled: Boolean = true,
var position: Int = 0,
var textColor: Int? = null,
var bgColor: Int? = null,
var underlineMode: Int = 0,
var underlineColor: Int? = null,
var underlineWidth: Float = 1f,
var underlineOffset: Float = 2f,
var underlineSvgPath: String? = null,
var bgImage: String? = null,
var bgImageFit: Int = 0,
var bgImageScale: Float = 1f,
) {
fun styleSummary(): String {
val parts = ArrayList<String>(4)
parts.add(targetScopeLabel())
textColor?.let {
parts.add("字色 ${it.toHexColor()}")
}
bgColor?.let {
parts.add("背景色 ${it.toHexColor()}")
}
if (underlineMode != 0) {
parts.add(
when (underlineMode) {
1 -> "实线下划线"
2 -> "虚线下划线"
3 -> "波浪下划线"
4 -> "双下划线"
5 -> "自定义SVG"
else -> "下划线"
} + underlineColor?.let { " ${it.toHexColor()}" }.orEmpty()
)
}
if (!bgImage.isNullOrBlank()) {
parts.add(
when (bgImageFit) {
1 -> "背景图(拉伸)"
2 -> "背景图(裁剪)"
else -> "背景图(平铺)"
}
)
}
if (parts.isEmpty()) {
parts.add("无样式")
}
return parts.joinToString(" / ")
}
fun targetScopeLabel(): String {
return when (targetScope) {
TARGET_TITLE -> "作用于标题"
TARGET_BODY -> "作用于正文"
else -> "作用于全部"
}
}
fun displayPattern(): String {
return pattern.ifBlank { ".*" }
}
fun normalizedSampleText(): String {
return sampleText.ifBlank {
"她轻声说:“今晚就出发。”\n最近在重读《百年孤独》(纪念版),节奏依然很稳。"
}
}
fun copyWithNewId(): HighlightRule {
return copy(id = UUID.randomUUID().toString())
}
companion object {
const val TARGET_ALL = 0
const val TARGET_TITLE = 1
const val TARGET_BODY = 2
fun Int.toHexColor(): String = String.format("#%08X", this)
}
}
@@ -0,0 +1,182 @@
package io.legado.app.data.repository
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.legado.app.constant.PreferKey
import io.legado.app.ui.book.manga.config.MangaScrollMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
data class MangaPreferences(
val showMangaUi: Boolean = true,
val disableMangaScale: Boolean = true,
val disableMangaScrollAnimation: Boolean = false,
val disableMangaCrossFade: Boolean = false,
val disableClickScroll: Boolean = false,
val mangaPreDownloadNum: Int = 10,
val mangaAutoPageSpeed: Int = 3,
val mangaFooterConfig: String = "",
val mangaScrollMode: Int = MangaScrollMode.WEBTOON,
val mangaLongClick: Boolean = true,
val mangaBackground: Int = 0xFF000000.toInt(),
val mangaColorFilter: String = "",
val hideMangaTitle: Boolean = false,
val enableMangaEInk: Boolean = false,
val mangaEInkThreshold: Int = 150,
val enableMangaGray: Boolean = false,
val webtoonSidePaddingDp: Int = 0,
val mangaVolumeKeyPage: Boolean = false,
val reverseVolumeKeyPage: Boolean = false,
val clickActionTL: Int = -1,
val clickActionTC: Int = -1,
val clickActionTR: Int = 1,
val clickActionML: Int = 2,
val clickActionMC: Int = 0,
val clickActionMR: Int = 1,
val clickActionBL: Int = 2,
val clickActionBC: Int = 1,
val clickActionBR: Int = 1
)
class MangaSettingsRepository(
private val context: Context,
private val settingsRepository: SettingsRepository
) {
val preferences: Flow<MangaPreferences> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences.toMangaPreferences()
}
suspend fun setShowMangaUi(value: Boolean) =
settingsRepository.putBoolean(PreferKey.showMangaUi, value)
suspend fun setMangaPreDownloadNum(value: Int) =
settingsRepository.putInt(PreferKey.mangaPreDownloadNum, value)
suspend fun setMangaAutoPageSpeed(value: Int) =
settingsRepository.putInt(PreferKey.mangaAutoPageSpeed, value)
suspend fun setDisableClickScroll(value: Boolean) =
settingsRepository.putBoolean(PreferKey.disableClickScroll, value)
suspend fun setDisableMangaScrollAnimation(value: Boolean) =
settingsRepository.putBoolean(PreferKey.disableMangaScrollAnimation, value)
suspend fun setDisableMangaCrossFade(value: Boolean) =
settingsRepository.putBoolean(PreferKey.disableMangaCrossFade, value)
suspend fun setDisableMangaScale(value: Boolean) =
settingsRepository.putBoolean(PreferKey.disableMangaScale, value)
suspend fun setEnableMangaEInk(value: Boolean) =
settingsRepository.putBoolean(PreferKey.enableMangaEInk, value)
suspend fun setMangaEInkThreshold(value: Int) =
settingsRepository.putInt(PreferKey.mangaEInkThreshold, value)
suspend fun setEnableMangaGray(value: Boolean) =
settingsRepository.putBoolean(PreferKey.enableMangaGray, value)
suspend fun setMangaAutoColorFilter(value: String) =
settingsRepository.putString(PreferKey.mangaColorFilter, value)
suspend fun setMangaBackground(value: Int) =
settingsRepository.putInt(PreferKey.mangaBackground, value)
suspend fun setMangaLongClick(value: Boolean) =
settingsRepository.putBoolean(PreferKey.mangaLongClick, value)
suspend fun setMangaVolumeKeyPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.mangaVolumeKeyPage, value)
suspend fun setReverseVolumeKeyPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.reverseVolumeKeyPage, value)
suspend fun setHideMangaTitle(value: Boolean) =
settingsRepository.putBoolean(PreferKey.hideMangaTitle, value)
suspend fun setMangaFooterConfig(value: String) =
settingsRepository.putString(PreferKey.mangaFooterConfig, value)
suspend fun setMangaClickAction(key: String, value: Int) =
settingsRepository.putInt(key, value)
private fun Preferences.toMangaPreferences(): MangaPreferences {
return MangaPreferences(
showMangaUi = this[Keys.ShowMangaUi] ?: true,
disableMangaScale = this[Keys.DisableMangaScale] ?: true,
disableMangaScrollAnimation = this[Keys.DisableMangaScrollAnimation] ?: false,
disableMangaCrossFade = this[Keys.DisableMangaCrossFade] ?: false,
disableClickScroll = this[Keys.DisableClickScroll] ?: false,
mangaPreDownloadNum = this[Keys.MangaPreDownloadNum] ?: 10,
mangaAutoPageSpeed = this[Keys.MangaAutoPageSpeed] ?: 3,
mangaFooterConfig = this[Keys.MangaFooterConfig] ?: "",
mangaScrollMode = this[Keys.MangaScrollMode] ?: MangaScrollMode.WEBTOON,
mangaLongClick = this[Keys.MangaLongClick] ?: true,
mangaBackground = this[Keys.MangaBackground] ?: 0xFF000000.toInt(),
mangaColorFilter = this[Keys.MangaColorFilter] ?: "",
hideMangaTitle = this[Keys.HideMangaTitle] ?: false,
enableMangaEInk = this[Keys.EnableMangaEInk] ?: false,
mangaEInkThreshold = this[Keys.MangaEInkThreshold] ?: 150,
enableMangaGray = this[Keys.EnableMangaGray] ?: false,
webtoonSidePaddingDp = this[Keys.WebtoonSidePaddingDp] ?: 0,
mangaVolumeKeyPage = this[Keys.MangaVolumeKeyPage] ?: false,
reverseVolumeKeyPage = this[Keys.ReverseVolumeKeyPage] ?: false,
clickActionTL = this[Keys.ClickActionTL] ?: -1,
clickActionTC = this[Keys.ClickActionTC] ?: -1,
clickActionTR = this[Keys.ClickActionTR] ?: 1,
clickActionML = this[Keys.ClickActionML] ?: 2,
clickActionMC = this[Keys.ClickActionMC] ?: 0,
clickActionMR = this[Keys.ClickActionMR] ?: 1,
clickActionBL = this[Keys.ClickActionBL] ?: 2,
clickActionBC = this[Keys.ClickActionBC] ?: 1,
clickActionBR = this[Keys.ClickActionBR] ?: 1
)
}
private object Keys {
val ShowMangaUi = booleanPreferencesKey(PreferKey.showMangaUi)
val DisableMangaScale = booleanPreferencesKey(PreferKey.disableMangaScale)
val DisableMangaScrollAnimation =
booleanPreferencesKey(PreferKey.disableMangaScrollAnimation)
val DisableMangaCrossFade = booleanPreferencesKey(PreferKey.disableMangaCrossFade)
val DisableClickScroll = booleanPreferencesKey(PreferKey.disableClickScroll)
val MangaPreDownloadNum = intPreferencesKey(PreferKey.mangaPreDownloadNum)
val MangaAutoPageSpeed = intPreferencesKey(PreferKey.mangaAutoPageSpeed)
val MangaFooterConfig = stringPreferencesKey(PreferKey.mangaFooterConfig)
val MangaScrollMode = intPreferencesKey(PreferKey.mangaScrollMode)
val MangaLongClick = booleanPreferencesKey(PreferKey.mangaLongClick)
val MangaBackground = intPreferencesKey(PreferKey.mangaBackground)
val MangaColorFilter = stringPreferencesKey(PreferKey.mangaColorFilter)
val HideMangaTitle = booleanPreferencesKey(PreferKey.hideMangaTitle)
val EnableMangaEInk = booleanPreferencesKey(PreferKey.enableMangaEInk)
val MangaEInkThreshold = intPreferencesKey(PreferKey.mangaEInkThreshold)
val EnableMangaGray = booleanPreferencesKey(PreferKey.enableMangaGray)
val WebtoonSidePaddingDp = intPreferencesKey(PreferKey.webtoonSidePaddingDp)
val MangaVolumeKeyPage = booleanPreferencesKey(PreferKey.mangaVolumeKeyPage)
val ReverseVolumeKeyPage = booleanPreferencesKey(PreferKey.reverseVolumeKeyPage)
val ClickActionTL = intPreferencesKey(PreferKey.mangaClickActionTL)
val ClickActionTC = intPreferencesKey(PreferKey.mangaClickActionTC)
val ClickActionTR = intPreferencesKey(PreferKey.mangaClickActionTR)
val ClickActionML = intPreferencesKey(PreferKey.mangaClickActionML)
val ClickActionMC = intPreferencesKey(PreferKey.mangaClickActionMC)
val ClickActionMR = intPreferencesKey(PreferKey.mangaClickActionMR)
val ClickActionBL = intPreferencesKey(PreferKey.mangaClickActionBL)
val ClickActionBC = intPreferencesKey(PreferKey.mangaClickActionBC)
val ClickActionBR = intPreferencesKey(PreferKey.mangaClickActionBR)
}
}
@@ -0,0 +1,121 @@
package io.legado.app.data.repository
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import io.legado.app.constant.PreferKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
data class ReadAloudPreferences(
val ignoreAudioFocus: Boolean = false,
val mediaButtonOnExit: Boolean = true,
val readAloudByMediaButton: Boolean = false,
val pauseReadAloudWhilePhoneCalls: Boolean = false,
val readAloudWakeLock: Boolean = false,
val mediaButtonPerNext: Boolean = false,
val readAloudByPage: Boolean = false,
val systemMediaControlCompatibilityChange: Boolean = true,
val streamReadAloudAudio: Boolean = false,
val ttsTimer: Int = 0,
val ttsFollowSys: Boolean = true,
val ttsSpeechRate: Int = 5,
)
class ReadAloudSettingsRepository(
private val context: Context,
private val settingsRepository: SettingsRepository
) {
val preferences: Flow<ReadAloudPreferences> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences.toReadAloudPreferences()
}
suspend fun setIgnoreAudioFocus(value: Boolean) =
settingsRepository.putBoolean(PreferKey.ignoreAudioFocus, value)
suspend fun setMediaButtonOnExit(value: Boolean) =
settingsRepository.putBoolean(PreferKey.mediaButtonOnExit, value)
suspend fun setReadAloudByMediaButton(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readAloudByMediaButton, value)
suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) =
settingsRepository.putBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
suspend fun setReadAloudWakeLock(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readAloudWakeLock, value)
suspend fun setMediaButtonPerNext(value: Boolean) =
settingsRepository.putBoolean(KEY_MEDIA_BUTTON_PER_NEXT, value)
suspend fun setReadAloudByPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readAloudByPage, value)
suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) =
settingsRepository.putBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
suspend fun setStreamReadAloudAudio(value: Boolean) =
settingsRepository.putBoolean(PreferKey.streamReadAloudAudio, value)
suspend fun setTtsTimer(value: Int) =
settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180))
suspend fun setTtsFollowSys(value: Boolean) =
settingsRepository.putBoolean(PreferKey.ttsFollowSys, value)
suspend fun setTtsSpeechRate(value: Int) =
settingsRepository.putInt(PreferKey.ttsSpeechRate, value.coerceIn(0, 80))
private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
return ReadAloudPreferences(
ignoreAudioFocus = this[Keys.IgnoreAudioFocus] ?: false,
mediaButtonOnExit = this[Keys.MediaButtonOnExit] ?: true,
readAloudByMediaButton = this[Keys.ReadAloudByMediaButton] ?: false,
pauseReadAloudWhilePhoneCalls = this[Keys.PauseReadAloudWhilePhoneCalls] ?: false,
readAloudWakeLock = this[Keys.ReadAloudWakeLock] ?: false,
mediaButtonPerNext = this[Keys.MediaButtonPerNext] ?: false,
readAloudByPage = this[Keys.ReadAloudByPage] ?: false,
systemMediaControlCompatibilityChange =
this[Keys.SystemMediaControlCompatibilityChange] ?: true,
streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
ttsTimer = this[Keys.TtsTimer] ?: 0,
ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
)
}
private object Keys {
val IgnoreAudioFocus = booleanPreferencesKey(PreferKey.ignoreAudioFocus)
val MediaButtonOnExit = booleanPreferencesKey(PreferKey.mediaButtonOnExit)
val ReadAloudByMediaButton = booleanPreferencesKey(PreferKey.readAloudByMediaButton)
val PauseReadAloudWhilePhoneCalls =
booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
val MediaButtonPerNext = booleanPreferencesKey(KEY_MEDIA_BUTTON_PER_NEXT)
val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
val SystemMediaControlCompatibilityChange =
booleanPreferencesKey(PreferKey.systemMediaControlCompatibilityChange)
val StreamReadAloudAudio = booleanPreferencesKey(PreferKey.streamReadAloudAudio)
val TtsTimer = androidx.datastore.preferences.core.intPreferencesKey(PreferKey.ttsTimer)
val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
val TtsSpeechRate = androidx.datastore.preferences.core.intPreferencesKey(
PreferKey.ttsSpeechRate
)
}
companion object {
const val KEY_MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
}
}
@@ -0,0 +1,61 @@
package io.legado.app.data.repository
import io.legado.app.help.config.ReadBookConfig
import java.io.InputStream
/**
* Repository boundary for the legacy read style config file.
*
* The underlying model is still [ReadBookConfig.Config] because each read style
* keeps a full layout/background/text configuration. This wrapper centralizes
* file mutations so UI and ViewModel code do not call persistence helpers directly.
*/
class ReadBookStyleConfigRepository {
fun save() {
ReadBookConfig.save()
}
fun addStyle(): Int {
ReadBookConfig.configList.add(ReadBookConfig.Config())
save()
return ReadBookConfig.configList.lastIndex
}
fun deleteCurrentStyle(): Boolean {
val deleted = ReadBookConfig.deleteDur()
if (deleted) {
save()
}
return deleted
}
fun importCurrentStyle(bytes: ByteArray) {
ReadBookConfig.durConfig = ReadBookConfig.import(bytes)
save()
}
fun exportCurrentStyle(): ByteArray {
return ReadBookConfig.export()
}
fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String {
return ReadBookConfig.saveBackgroundImage(inputStream, displayName)
}
fun setCurrentBackgroundImage(path: String) {
ReadBookConfig.durConfig.setCurBg(2, path)
save()
}
fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) {
if (isNight) {
ReadBookConfig.durConfig.bgTypeNight = 2
ReadBookConfig.durConfig.bgStrNight = path
} else {
ReadBookConfig.durConfig.bgType = 2
ReadBookConfig.durConfig.bgStr = path
}
save()
}
}
@@ -0,0 +1,537 @@
package io.legado.app.data.repository
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.legado.app.constant.PreferKey
import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.constant.ReadMenuBlurStyle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
data class ReadPreferences(
val screenOrientation: String = "0",
val keepLight: String = "0",
val hideStatusBar: Boolean = false,
val hideNavigationBar: Boolean = false,
val paddingDisplayCutouts: Boolean = false,
val titleBarMode: String = "1",
val menuAlpha: Int = 100,
val readBodyToLh: Boolean = true,
val defaultSourceChangeAll: Boolean = true,
val textFullJustify: Boolean = true,
val textBottomJustify: Boolean = true,
val adaptSpecialStyle: Boolean = true,
val useZhLayout: Boolean = false,
val showBrightnessView: Boolean = true,
val useUnderline: Boolean = false,
val readSliderMode: String = "0",
val doubleHorizontalPage: String = "0",
val progressBarBehavior: String = "page",
val mouseWheelPage: Boolean = true,
val volumeKeyPage: Boolean = true,
val volumeKeyPageOnPlay: Boolean = true,
val keyPageOnLongPress: Boolean = false,
val pageTouchSlop: Int = 0,
val sliderVibrator: Boolean = false,
val selectVibrator: Boolean = false,
val autoChangeSource: Boolean = true,
val selectText: Boolean = true,
val noAnimScrollPage: Boolean = false,
val clickImgWay: String = "2",
val optimizeRender: Boolean = false,
val disableReturnKey: Boolean = false,
val expandTextMenu: Boolean = false,
val showReadTitleAddition: Boolean = true,
val autoReadSpeed: Int = 10,
val prevKeys: String = "",
val nextKeys: String = "",
val tocUiUseReplace: Boolean = false,
val tocCountWords: Boolean = true,
val readStyleSelect: Int = 0,
val comicStyleSelect: Int = 0,
val shareLayout: Boolean = false,
val readBarStyleFollowPage: Boolean = false,
val readBarStyle: Int = 0,
val clickActionTL: Int = 2,
val clickActionTC: Int = 2,
val clickActionTR: Int = 1,
val clickActionML: Int = 2,
val clickActionMC: Int = 0,
val clickActionMR: Int = 1,
val clickActionBL: Int = 2,
val clickActionBC: Int = 1,
val clickActionBR: Int = 1,
val fontFolder: String = "",
val readMenuBgColor: Int = 0,
val readMenuAccentColor: Int = 0,
val readMenuContainerColor: Int = 0,
val readMenuBgColorNight: Int = 0,
val readMenuAccentColorNight: Int = 0,
val readMenuContainerColorNight: Int = 0,
val readMenuColorMode: Int = 1,
val readMenuIconShowText: Boolean = true,
val readMenuIconStyle: Int = 0,
val readMenuIconItemsPerRow: Int = 5,
val readMenuIconRowCount: Int = 1,
val readMenuBottomCornerRadius: Int = 0,
val readMenuFloatingBottomBar: Boolean = false,
val readMenuTopBarBlurMode: Int = ReadMenuBlurMode.None,
val readMenuBottomBarBlurMode: Int = ReadMenuBlurMode.None,
val readMenuTopBarLiquidGlassButtons: Boolean = false,
val readMenuBottomBarLiquidGlassButtons: Boolean = false,
val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
val readMenuBlurRadius: Int = 24,
val readMenuBlurAlpha: Int = 60,
val readMenuLensRadius: Float = 24f,
val readMenuBorderWidth: Int = 0,
val readMenuBorderColor: Int = 0,
val readMenuBorderColorNight: Int = 0,
val readMenuCustomIcons: String = "",
val titleBarCustomIcons: String = "",
val titleBarIconPosition: Int = 0,
val showTitleBarIcons: Boolean = true,
val chineseConverterType: Int = 0,
)
class ReadSettingsRepository(
private val context: Context,
private val settingsRepository: SettingsRepository
) {
val preferences: Flow<ReadPreferences> = context.dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences.toReadPreferences()
}
suspend fun setScreenOrientation(value: String) =
settingsRepository.putString(PreferKey.screenOrientation, value)
suspend fun setKeepLight(value: String) =
settingsRepository.putString(PreferKey.keepLight, value)
suspend fun setHideStatusBar(value: Boolean) =
settingsRepository.putBoolean(PreferKey.hideStatusBar, value)
suspend fun setHideNavigationBar(value: Boolean) =
settingsRepository.putBoolean(PreferKey.hideNavigationBar, value)
suspend fun setPaddingDisplayCutouts(value: Boolean) =
settingsRepository.putBoolean(PreferKey.paddingDisplayCutouts, value)
suspend fun setTitleBarMode(value: String) =
settingsRepository.putString(PreferKey.titleBarMode, value)
suspend fun setMenuAlpha(value: Int) =
settingsRepository.putInt(PreferKey.menuAlpha, value)
suspend fun setReadBodyToLh(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readBodyToLh, value)
suspend fun setDefaultSourceChangeAll(value: Boolean) =
settingsRepository.putBoolean(PreferKey.defaultSourceChangeAll, value)
suspend fun setTextFullJustify(value: Boolean) =
settingsRepository.putBoolean(PreferKey.textFullJustify, value)
suspend fun setTextBottomJustify(value: Boolean) =
settingsRepository.putBoolean(PreferKey.textBottomJustify, value)
suspend fun setAdaptSpecialStyle(value: Boolean) =
settingsRepository.putBoolean(PreferKey.adaptSpecialStyle, value)
suspend fun setUseZhLayout(value: Boolean) =
settingsRepository.putBoolean(PreferKey.useZhLayout, value)
suspend fun setShowBrightnessView(value: Boolean) =
settingsRepository.putBoolean(PreferKey.showBrightnessView, value)
suspend fun setUseUnderline(value: Boolean) =
settingsRepository.putBoolean(PreferKey.useUnderline, value)
suspend fun setReadSliderMode(value: String) =
settingsRepository.putString(PreferKey.readSliderMode, value)
suspend fun setDoubleHorizontalPage(value: String) =
settingsRepository.putString(PreferKey.doublePageHorizontal, value)
suspend fun setProgressBarBehavior(value: String) =
settingsRepository.putString(PreferKey.progressBarBehavior, value)
suspend fun setMouseWheelPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.mouseWheelPage, value)
suspend fun setVolumeKeyPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.volumeKeyPage, value)
suspend fun setVolumeKeyPageOnPlay(value: Boolean) =
settingsRepository.putBoolean(PreferKey.volumeKeyPageOnPlay, value)
suspend fun setKeyPageOnLongPress(value: Boolean) =
settingsRepository.putBoolean(PreferKey.keyPageOnLongPress, value)
suspend fun setPageTouchSlop(value: Int) =
settingsRepository.putInt(PreferKey.pageTouchSlop, value)
suspend fun setSliderVibrator(value: Boolean) =
settingsRepository.putBoolean(PreferKey.sliderVibrator, value)
suspend fun setSelectVibrator(value: Boolean) =
settingsRepository.putBoolean(PreferKey.selectVibrator, value)
suspend fun setAutoChangeSource(value: Boolean) =
settingsRepository.putBoolean(PreferKey.autoChangeSource, value)
suspend fun setSelectText(value: Boolean) =
settingsRepository.putBoolean(PreferKey.selectText, value)
suspend fun setNoAnimScrollPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.noAnimScrollPage, value)
suspend fun setClickImgWay(value: String) =
settingsRepository.putString(PreferKey.clickImgWay, value)
suspend fun setOptimizeRender(value: Boolean) =
settingsRepository.putBoolean(PreferKey.optimizeRender, value)
suspend fun setDisableReturnKey(value: Boolean) =
settingsRepository.putBoolean(PreferKey.disableReturnKey, value)
suspend fun setExpandTextMenu(value: Boolean) =
settingsRepository.putBoolean(PreferKey.expandTextMenu, value)
suspend fun setShowReadTitleAddition(value: Boolean) =
settingsRepository.putBoolean(PreferKey.showReadTitleAddition, value)
suspend fun setAutoReadSpeed(value: Int) =
settingsRepository.putInt(PreferKey.autoReadSpeed, value)
suspend fun setPageKeys(prevKeys: String, nextKeys: String) {
settingsRepository.putStrings(
mapOf(
PreferKey.prevKeys to prevKeys,
PreferKey.nextKeys to nextKeys
)
)
}
suspend fun setTocUiUseReplace(value: Boolean) =
settingsRepository.putBoolean(PreferKey.tocUiUseReplace, value)
suspend fun setTocCountWords(value: Boolean) =
settingsRepository.putBoolean(PreferKey.tocCountWords, value)
suspend fun setReadStyleSelect(value: Int) =
settingsRepository.putInt(PreferKey.readStyleSelect, value)
suspend fun setComicStyleSelect(value: Int) =
settingsRepository.putInt(PreferKey.comicStyleSelect, value)
suspend fun setShareLayout(value: Boolean) =
settingsRepository.putBoolean(PreferKey.shareLayout, value)
suspend fun setReadBarStyleFollowPage(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readBarStyleFollowPage, value)
suspend fun setReadBarStyle(value: Int) =
settingsRepository.putInt(PreferKey.readBarStyle, value.coerceIn(0, 2))
suspend fun setClickAction(key: String, value: Int) =
settingsRepository.putInt(key, value)
suspend fun setFontFolder(value: String) =
settingsRepository.putString(PreferKey.fontFolder, value)
suspend fun setReadMenuBgColor(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBgColor, value)
suspend fun setReadMenuAccentColor(value: Int) =
settingsRepository.putInt(PreferKey.readMenuAccentColor, value)
suspend fun setReadMenuContainerColor(value: Int) =
settingsRepository.putInt(PreferKey.readMenuContainerColor, value)
suspend fun setReadMenuBgColorNight(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBgColorNight, value)
suspend fun setReadMenuAccentColorNight(value: Int) =
settingsRepository.putInt(PreferKey.readMenuAccentColorNight, value)
suspend fun setReadMenuContainerColorNight(value: Int) =
settingsRepository.putInt(PreferKey.readMenuContainerColorNight, value)
suspend fun setReadMenuColorMode(value: Int) =
settingsRepository.putInt(PreferKey.readMenuColorMode, value.coerceIn(0, 1))
suspend fun setReadMenuIconShowText(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readMenuIconShowText, value)
suspend fun setReadMenuIconStyle(value: Int) =
settingsRepository.putInt(PreferKey.readMenuIconStyle, value.coerceIn(0, 2))
suspend fun setReadMenuIconItemsPerRow(value: Int) =
settingsRepository.putInt(PreferKey.readMenuIconItemsPerRow, value.coerceIn(2, 8))
suspend fun setReadMenuIconRowCount(value: Int) =
settingsRepository.putInt(PreferKey.readMenuIconRowCount, value.coerceIn(1, 2))
suspend fun setReadMenuBottomCornerRadius(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBottomCornerRadius, value.coerceIn(0, 32))
suspend fun setReadMenuFloatingBottomBar(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readMenuFloatingBottomBar, value)
suspend fun setReadMenuTopBarBlurMode(value: Int) =
settingsRepository.putInt(PreferKey.readMenuTopBarBlurMode, value.coerceIn(0, 2))
suspend fun setReadMenuBottomBarBlurMode(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBottomBarBlurMode, value.coerceIn(0, 2))
suspend fun setReadMenuTopBarLiquidGlassButtons(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readMenuTopBarLiquidGlassButtons, value)
suspend fun setReadMenuBottomBarLiquidGlassButtons(value: Boolean) =
settingsRepository.putBoolean(PreferKey.readMenuBottomBarLiquidGlassButtons, value)
suspend fun setReadMenuTopBarBlurStyle(value: Int) =
settingsRepository.putInt(PreferKey.readMenuTopBarBlurStyle, value.coerceIn(0, 1))
suspend fun setReadMenuBottomBarBlurStyle(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBottomBarBlurStyle, value.coerceIn(0, 1))
suspend fun setReadMenuBlurRadius(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBlurRadius, value.coerceIn(0, 32))
suspend fun setReadMenuBlurAlpha(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBlurAlpha, value.coerceIn(0, 100))
suspend fun setReadMenuLensRadius(value: Float) =
settingsRepository.putFloat(PreferKey.readMenuLensRadius, value.coerceIn(0f, 48f))
suspend fun setReadMenuBorderWidth(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBorderWidth, value.coerceIn(0, 4))
suspend fun setReadMenuBorderColor(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBorderColor, value)
suspend fun setReadMenuBorderColorNight(value: Int) =
settingsRepository.putInt(PreferKey.readMenuBorderColorNight, value)
suspend fun setReadMenuCustomIcons(value: String) =
settingsRepository.putString(PreferKey.readMenuCustomIcons, value)
suspend fun setTitleBarCustomIcons(value: String) =
settingsRepository.putString(PreferKey.titleBarCustomIcons, value)
suspend fun setTitleBarIconPosition(value: Int) =
settingsRepository.putInt(PreferKey.titleBarIconPosition, value.coerceIn(0, 3))
suspend fun setShowTitleBarIcons(value: Boolean) =
settingsRepository.putBoolean(PreferKey.showTitleBarIcons, value)
suspend fun setChineseConverterType(value: Int) =
settingsRepository.putInt(PreferKey.chineseConverterType, value)
suspend fun setStyleSelect(isComic: Boolean, value: Int) {
if (isComic) {
setComicStyleSelect(value)
} else {
setReadStyleSelect(value)
}
}
private fun Preferences.toReadPreferences(): ReadPreferences {
val readStyleSelect = this[Keys.ReadStyleSelect] ?: 0
return ReadPreferences(
screenOrientation = this[Keys.ScreenOrientation] ?: "0",
keepLight = this[Keys.KeepLight] ?: "0",
hideStatusBar = this[Keys.HideStatusBar] ?: false,
hideNavigationBar = this[Keys.HideNavigationBar] ?: false,
paddingDisplayCutouts = this[Keys.PaddingDisplayCutouts] ?: false,
titleBarMode = this[Keys.TitleBarMode] ?: "1",
menuAlpha = this[Keys.MenuAlpha] ?: 100,
readBodyToLh = this[Keys.ReadBodyToLh] ?: true,
defaultSourceChangeAll = this[Keys.DefaultSourceChangeAll] ?: true,
textFullJustify = this[Keys.TextFullJustify] ?: true,
textBottomJustify = this[Keys.TextBottomJustify] ?: true,
adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true,
useZhLayout = this[Keys.UseZhLayout] ?: false,
showBrightnessView = this[Keys.ShowBrightnessView] ?: true,
useUnderline = this[Keys.UseUnderline] ?: false,
readSliderMode = this[Keys.ReadSliderMode] ?: "0",
doubleHorizontalPage = this[Keys.DoubleHorizontalPage] ?: "0",
progressBarBehavior = this[Keys.ProgressBarBehavior] ?: "page",
mouseWheelPage = this[Keys.MouseWheelPage] ?: true,
volumeKeyPage = this[Keys.VolumeKeyPage] ?: true,
volumeKeyPageOnPlay = this[Keys.VolumeKeyPageOnPlay] ?: true,
keyPageOnLongPress = this[Keys.KeyPageOnLongPress] ?: false,
pageTouchSlop = this[Keys.PageTouchSlop] ?: 0,
sliderVibrator = this[Keys.SliderVibrator] ?: false,
selectVibrator = this[Keys.SelectVibrator] ?: false,
autoChangeSource = this[Keys.AutoChangeSource] ?: true,
selectText = this[Keys.SelectText] ?: true,
noAnimScrollPage = this[Keys.NoAnimScrollPage] ?: false,
clickImgWay = this[Keys.ClickImgWay] ?: "2",
optimizeRender = this[Keys.OptimizeRender] ?: false,
disableReturnKey = this[Keys.DisableReturnKey] ?: false,
expandTextMenu = this[Keys.ExpandTextMenu] ?: false,
showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true,
autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10,
prevKeys = this[Keys.PrevKeys] ?: "",
nextKeys = this[Keys.NextKeys] ?: "",
tocUiUseReplace = this[Keys.TocUiUseReplace] ?: false,
tocCountWords = this[Keys.TocCountWords] ?: true,
readStyleSelect = readStyleSelect,
comicStyleSelect = this[Keys.ComicStyleSelect] ?: readStyleSelect,
shareLayout = this[Keys.ShareLayout] ?: false,
readBarStyleFollowPage = this[Keys.ReadBarStyleFollowPage] ?: false,
readBarStyle = this[Keys.ReadBarStyle] ?: 0,
clickActionTL = this[Keys.ClickActionTL] ?: 2,
clickActionTC = this[Keys.ClickActionTC] ?: 2,
clickActionTR = this[Keys.ClickActionTR] ?: 1,
clickActionML = this[Keys.ClickActionML] ?: 2,
clickActionMC = this[Keys.ClickActionMC] ?: 0,
clickActionMR = this[Keys.ClickActionMR] ?: 1,
clickActionBL = this[Keys.ClickActionBL] ?: 2,
clickActionBC = this[Keys.ClickActionBC] ?: 1,
clickActionBR = this[Keys.ClickActionBR] ?: 1,
fontFolder = this[Keys.FontFolder] ?: "",
readMenuBgColor = this[Keys.ReadMenuBgColor] ?: 0,
readMenuAccentColor = this[Keys.ReadMenuAccentColor] ?: 0,
readMenuContainerColor = this[Keys.ReadMenuContainerColor] ?: 0,
readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0,
readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0,
readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0,
readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1,
readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true,
readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0,
readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5,
readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1,
readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0,
readMenuFloatingBottomBar = this[Keys.ReadMenuFloatingBottomBar] ?: false,
readMenuTopBarBlurMode = this[Keys.ReadMenuTopBarBlurMode] ?: ReadMenuBlurMode.None,
readMenuBottomBarBlurMode = this[Keys.ReadMenuBottomBarBlurMode]
?: ReadMenuBlurMode.None,
readMenuTopBarLiquidGlassButtons = this[Keys.ReadMenuTopBarLiquidGlassButtons] ?: false,
readMenuBottomBarLiquidGlassButtons = this[Keys.ReadMenuBottomBarLiquidGlassButtons]
?: false,
readMenuTopBarBlurStyle = this[Keys.ReadMenuTopBarBlurStyle]
?: ReadMenuBlurStyle.Progressive,
readMenuBottomBarBlurStyle = this[Keys.ReadMenuBottomBarBlurStyle]
?: ReadMenuBlurStyle.Solid,
readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24,
readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60,
readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f,
readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0,
readMenuBorderColor = this[Keys.ReadMenuBorderColor] ?: 0,
readMenuBorderColorNight = this[Keys.ReadMenuBorderColorNight] ?: 0,
readMenuCustomIcons = this[Keys.ReadMenuCustomIcons] ?: "",
titleBarCustomIcons = this[Keys.TitleBarCustomIcons] ?: "",
titleBarIconPosition = this[Keys.TitleBarIconPosition] ?: 0,
showTitleBarIcons = this[Keys.ShowTitleBarIcons] ?: true,
chineseConverterType = this[Keys.ChineseConverterType] ?: 0,
)
}
private object Keys {
val ScreenOrientation = stringPreferencesKey(PreferKey.screenOrientation)
val KeepLight = stringPreferencesKey(PreferKey.keepLight)
val HideStatusBar = booleanPreferencesKey(PreferKey.hideStatusBar)
val HideNavigationBar = booleanPreferencesKey(PreferKey.hideNavigationBar)
val PaddingDisplayCutouts = booleanPreferencesKey(PreferKey.paddingDisplayCutouts)
val TitleBarMode = stringPreferencesKey(PreferKey.titleBarMode)
val MenuAlpha = intPreferencesKey(PreferKey.menuAlpha)
val ReadBodyToLh = booleanPreferencesKey(PreferKey.readBodyToLh)
val DefaultSourceChangeAll = booleanPreferencesKey(PreferKey.defaultSourceChangeAll)
val TextFullJustify = booleanPreferencesKey(PreferKey.textFullJustify)
val TextBottomJustify = booleanPreferencesKey(PreferKey.textBottomJustify)
val AdaptSpecialStyle = booleanPreferencesKey(PreferKey.adaptSpecialStyle)
val UseZhLayout = booleanPreferencesKey(PreferKey.useZhLayout)
val ShowBrightnessView = booleanPreferencesKey(PreferKey.showBrightnessView)
val UseUnderline = booleanPreferencesKey(PreferKey.useUnderline)
val ReadSliderMode = stringPreferencesKey(PreferKey.readSliderMode)
val DoubleHorizontalPage = stringPreferencesKey(PreferKey.doublePageHorizontal)
val ProgressBarBehavior = stringPreferencesKey(PreferKey.progressBarBehavior)
val MouseWheelPage = booleanPreferencesKey(PreferKey.mouseWheelPage)
val VolumeKeyPage = booleanPreferencesKey(PreferKey.volumeKeyPage)
val VolumeKeyPageOnPlay = booleanPreferencesKey(PreferKey.volumeKeyPageOnPlay)
val KeyPageOnLongPress = booleanPreferencesKey(PreferKey.keyPageOnLongPress)
val PageTouchSlop = intPreferencesKey(PreferKey.pageTouchSlop)
val SliderVibrator = booleanPreferencesKey(PreferKey.sliderVibrator)
val SelectVibrator = booleanPreferencesKey(PreferKey.selectVibrator)
val AutoChangeSource = booleanPreferencesKey(PreferKey.autoChangeSource)
val SelectText = booleanPreferencesKey(PreferKey.selectText)
val NoAnimScrollPage = booleanPreferencesKey(PreferKey.noAnimScrollPage)
val ClickImgWay = stringPreferencesKey(PreferKey.clickImgWay)
val OptimizeRender = booleanPreferencesKey(PreferKey.optimizeRender)
val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey)
val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
val PrevKeys = stringPreferencesKey(PreferKey.prevKeys)
val NextKeys = stringPreferencesKey(PreferKey.nextKeys)
val TocUiUseReplace = booleanPreferencesKey(PreferKey.tocUiUseReplace)
val TocCountWords = booleanPreferencesKey(PreferKey.tocCountWords)
val ReadStyleSelect = intPreferencesKey(PreferKey.readStyleSelect)
val ComicStyleSelect = intPreferencesKey(PreferKey.comicStyleSelect)
val ShareLayout = booleanPreferencesKey(PreferKey.shareLayout)
val ReadBarStyleFollowPage = booleanPreferencesKey(PreferKey.readBarStyleFollowPage)
val ReadBarStyle = intPreferencesKey(PreferKey.readBarStyle)
val ClickActionTL = intPreferencesKey(PreferKey.clickActionTL)
val ClickActionTC = intPreferencesKey(PreferKey.clickActionTC)
val ClickActionTR = intPreferencesKey(PreferKey.clickActionTR)
val ClickActionML = intPreferencesKey(PreferKey.clickActionML)
val ClickActionMC = intPreferencesKey(PreferKey.clickActionMC)
val ClickActionMR = intPreferencesKey(PreferKey.clickActionMR)
val ClickActionBL = intPreferencesKey(PreferKey.clickActionBL)
val ClickActionBC = intPreferencesKey(PreferKey.clickActionBC)
val ClickActionBR = intPreferencesKey(PreferKey.clickActionBR)
val FontFolder = stringPreferencesKey(PreferKey.fontFolder)
val ReadMenuBgColor = intPreferencesKey(PreferKey.readMenuBgColor)
val ReadMenuAccentColor = intPreferencesKey(PreferKey.readMenuAccentColor)
val ReadMenuContainerColor = intPreferencesKey(PreferKey.readMenuContainerColor)
val ReadMenuBgColorNight = intPreferencesKey(PreferKey.readMenuBgColorNight)
val ReadMenuAccentColorNight = intPreferencesKey(PreferKey.readMenuAccentColorNight)
val ReadMenuContainerColorNight = intPreferencesKey(PreferKey.readMenuContainerColorNight)
val ReadMenuColorMode = intPreferencesKey(PreferKey.readMenuColorMode)
val ReadMenuIconShowText = booleanPreferencesKey(PreferKey.readMenuIconShowText)
val ReadMenuIconStyle = intPreferencesKey(PreferKey.readMenuIconStyle)
val ReadMenuIconItemsPerRow = intPreferencesKey(PreferKey.readMenuIconItemsPerRow)
val ReadMenuIconRowCount = intPreferencesKey(PreferKey.readMenuIconRowCount)
val ReadMenuBottomCornerRadius = intPreferencesKey(PreferKey.readMenuBottomCornerRadius)
val ReadMenuFloatingBottomBar = booleanPreferencesKey(PreferKey.readMenuFloatingBottomBar)
val ReadMenuTopBarBlurMode = intPreferencesKey(PreferKey.readMenuTopBarBlurMode)
val ReadMenuBottomBarBlurMode = intPreferencesKey(PreferKey.readMenuBottomBarBlurMode)
val ReadMenuTopBarLiquidGlassButtons =
booleanPreferencesKey(PreferKey.readMenuTopBarLiquidGlassButtons)
val ReadMenuBottomBarLiquidGlassButtons =
booleanPreferencesKey(PreferKey.readMenuBottomBarLiquidGlassButtons)
val ReadMenuTopBarBlurStyle = intPreferencesKey(PreferKey.readMenuTopBarBlurStyle)
val ReadMenuBottomBarBlurStyle = intPreferencesKey(PreferKey.readMenuBottomBarBlurStyle)
val ReadMenuBlurRadius = intPreferencesKey(PreferKey.readMenuBlurRadius)
val ReadMenuBlurAlpha = intPreferencesKey(PreferKey.readMenuBlurAlpha)
val ReadMenuLensRadius = floatPreferencesKey(PreferKey.readMenuLensRadius)
val ReadMenuBorderWidth = intPreferencesKey(PreferKey.readMenuBorderWidth)
val ReadMenuBorderColor = intPreferencesKey(PreferKey.readMenuBorderColor)
val ReadMenuBorderColorNight = intPreferencesKey(PreferKey.readMenuBorderColorNight)
val ReadMenuCustomIcons = stringPreferencesKey(PreferKey.readMenuCustomIcons)
val TitleBarCustomIcons = stringPreferencesKey(PreferKey.titleBarCustomIcons)
val TitleBarIconPosition = intPreferencesKey(PreferKey.titleBarIconPosition)
val ShowTitleBarIcons = booleanPreferencesKey(PreferKey.showTitleBarIcons)
val ChineseConverterType = intPreferencesKey(PreferKey.chineseConverterType)
}
}
@@ -0,0 +1,255 @@
package io.legado.app.data.repository
import androidx.core.graphics.toColorInt
import io.legado.app.constant.AppLog
import io.legado.app.help.DefaultData
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadStyleResolver
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFolderReplace
import io.legado.app.utils.externalCache
import io.legado.app.utils.externalFiles
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getFile
import io.legado.app.utils.printOnDebug
import splitties.init.appCtx
import java.io.File
import java.io.InputStream
class ReadStyleRepository {
val configFilePath: String =
FileUtils.getPath(appCtx.filesDir, ReadBookConfig.configFileName)
val shareConfigFilePath: String =
FileUtils.getPath(appCtx.filesDir, ReadBookConfig.shareConfigFileName)
fun readConfigs(): List<ReadBookConfig.Config> {
val configFile = File(configFilePath)
if (configFile.exists()) {
try {
return GSON.fromJsonArray<ReadBookConfig.Config>(configFile.readText()).getOrThrow()
} catch (e: Exception) {
AppLog.put("读取排版配置文件出错", e)
}
}
return DefaultData.readConfigs
}
fun readShareConfig(fallbackConfig: ReadBookConfig.Config): ReadBookConfig.Config {
val configFile = File(shareConfigFilePath)
if (configFile.exists()) {
try {
return GSON.fromJsonObject<ReadBookConfig.Config>(configFile.readText()).getOrThrow()
} catch (e: Exception) {
e.printOnDebug()
}
}
return fallbackConfig
}
fun save(
configs: List<ReadBookConfig.Config>,
shareConfig: ReadBookConfig.Config
) {
GSON.toJson(configs).let {
FileUtils.delete(configFilePath)
FileUtils.createFileIfNotExist(configFilePath).writeText(it)
}
GSON.toJson(shareConfig).let {
FileUtils.delete(shareConfigFilePath)
FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it)
}
}
fun getAllPicBgStr(configs: List<ReadBookConfig.Config>): ArrayList<String> {
val list = arrayListOf<String>()
configs.forEach {
if (it.bgType == 2) {
list.add(it.bgStr)
}
if (it.bgTypeNight == 2) {
list.add(it.bgStrNight)
}
if (it.bgTypeEInk == 2) {
list.add(it.bgStrEInk)
}
}
return list
}
fun clearBgAndCache(configs: List<ReadBookConfig.Config>) {
val bgs = hashSetOf<String>()
configs.forEach { config ->
repeat(3) {
config.getBgPath(it)?.let { path ->
bgs.add(path)
}
}
}
appCtx.externalFiles.getFile("bg").listFiles()?.forEach {
if (!bgs.contains(it.absolutePath)) {
it.delete()
}
}
FileUtils.delete(appCtx.externalCache.getFile("readConfig"))
FileUtils.delete(FileUtils.getPath(appCtx.externalCache, "readConfig.zip"))
}
fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String {
val bgDir = appCtx.externalFiles.getFile("bg")
bgDir.mkdirs()
val safeName = displayName
?.let { File(it).name }
?.takeIf { it.isNotBlank() }
?: "read_bg.jpg"
val baseName = File(safeName).nameWithoutExtension.ifBlank { "read_bg" }
val extension = File(safeName).extension.ifBlank { "jpg" }
val bgFile = File(bgDir, "${baseName}_${System.currentTimeMillis()}.$extension")
if (!FileUtils.writeInputStream(bgFile, inputStream)) {
error("save read background image failed")
}
return bgFile.absolutePath
}
fun export(config: ReadBookConfig.Config): ByteArray {
val exportDir = appCtx.externalCache.getFile("readConfigExport")
exportDir.createFolderReplace()
val exportConfig = config.copy(
regexColorRules = ArrayList(config.regexColorRules.map { it.copy() })
)
val exportFiles = arrayListOf<File>()
addBackgroundFile(exportDir, exportConfig, 0, exportFiles)
addBackgroundFile(exportDir, exportConfig, 1, exportFiles)
addBackgroundFile(exportDir, exportConfig, 2, exportFiles)
exportConfig.textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles)
exportConfig.titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles)
val configFile = exportDir.getFile(ReadBookConfig.configFileName)
configFile.writeText(GSON.toJson(exportConfig))
exportFiles.add(configFile)
val zipFile = appCtx.externalCache.getFile("readConfig.zip")
FileUtils.delete(zipFile)
ZipUtils.zipFiles(exportFiles, zipFile)
return zipFile.readBytes()
}
fun import(byteArray: ByteArray): ReadBookConfig.Config {
val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip")
FileUtils.delete(configZipPath)
val zipFile = FileUtils.createFileIfNotExist(configZipPath)
zipFile.writeBytes(byteArray)
val configDir = appCtx.externalCache.getFile("readConfig")
configDir.createFolderReplace()
ZipUtils.unZipToPath(zipFile, configDir)
val configFile = configDir.getFile(ReadBookConfig.configFileName)
val config: ReadBookConfig.Config =
GSON.fromJsonObject<ReadBookConfig.Config>(configFile.readText()).getOrThrow()
config.textFont = importFont(configDir, config.textFont)
config.titleFont = importFont(configDir, config.titleFont)
if (config.bgType == 2) {
val bgName = FileUtils.getName(config.bgStr)
config.bgStr = bgName
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
if (!FileUtils.exist(bgPath)) {
val bgFile = configDir.getFile(bgName)
if (bgFile.exists()) {
bgFile.copyTo(File(bgPath))
}
}
config.bgStr = bgPath
} else if (config.bgTypeNight == 0) {
config.bgStrNight.toColorInt()
}
if (config.bgTypeNight == 2) {
val bgName = FileUtils.getName(config.bgStrNight)
config.bgStrNight = bgName
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
if (!FileUtils.exist(bgPath)) {
val bgFile = configDir.getFile(bgName)
if (bgFile.exists()) {
bgFile.copyTo(File(bgPath))
}
}
config.bgStrNight = bgPath
}
if (config.bgTypeEInk == 2) {
val bgName = FileUtils.getName(config.bgStrEInk)
config.bgStrEInk = bgName
val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName)
if (!FileUtils.exist(bgPath)) {
val bgFile = configDir.getFile(bgName)
if (bgFile.exists()) {
bgFile.copyTo(File(bgPath))
}
}
config.bgStrEInk = bgPath
} else if (config.bgTypeEInk == 0) {
config.bgStrEInk.toColorInt()
}
config.curTextColor()
config.curTextAccentColor()
config.curTextShadowColor()
return config
}
private fun addBackgroundFile(
exportDir: File,
config: ReadBookConfig.Config,
bgIndex: Int,
exportFiles: MutableList<File>
) {
val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return
val exportedName = addAssetFile(exportDir, sourcePath, exportFiles)
if (exportedName.isBlank()) {
return
}
when (bgIndex) {
0 -> config.bgStr = exportedName
1 -> config.bgStrNight = exportedName
2 -> config.bgStrEInk = exportedName
}
}
private fun addAssetFile(
exportDir: File,
sourcePath: String,
exportFiles: MutableList<File>
): String {
if (sourcePath.isBlank()) {
return ""
}
val source = File(sourcePath)
if (!source.exists() || !source.isFile) {
return ""
}
val target = exportDir.getFile(source.name)
source.copyTo(target, overwrite = true)
if (exportFiles.none { it.absolutePath == target.absolutePath }) {
exportFiles.add(target)
}
return target.name
}
private fun importFont(configDir: File, fontName: String): String {
if (fontName.isEmpty()) {
return ""
}
val fontPath = FileUtils.getPath(appCtx.externalFiles, "font", fontName)
val fontFile = configDir.getFile(fontName)
return if (fontFile.exists()) {
if (!FileUtils.exist(fontPath)) {
fontFile.copyTo(File(fontPath))
}
fontPath
} else {
""
}
}
}
@@ -84,6 +84,17 @@ class SettingsRepository(private val context: Context) {
suspend fun putString(key: String, value: String) =
updatePreference(stringPreferencesKey(key), value)
suspend fun putStrings(values: Map<String, String>) {
dataStore.edit { preferences ->
values.forEach { (key, value) ->
preferences[stringPreferencesKey(key)] = value
}
}
values.forEach { (key, value) ->
syncToSharedPrefs(key, value)
}
}
// Int 类型的快捷访问
fun getInt(key: String, defaultValue: Int = 0): Flow<Int> =
getPreference(intPreferencesKey(key), defaultValue)
@@ -38,4 +38,5 @@ val appDatabaseModule = module {
factory<ServerDao> { get<AppDatabase>().serverDao }
factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
factory<HighlightRuleDao> { get<AppDatabase>().highlightRuleDao }
}
@@ -25,7 +25,11 @@ import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HomepageModulesRepository
import io.legado.app.data.repository.LlmTranslateRepositoryImpl
import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.MangaSettingsRepository
import io.legado.app.data.repository.ReadBookStyleConfigRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.ReadSettingsRepository
import io.legado.app.data.repository.RemoteBookRepository
import io.legado.app.data.repository.RssRepository
import io.legado.app.data.repository.SearchContentRepository
@@ -57,11 +61,13 @@ import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.CacheBookChaptersUseCase
import io.legado.app.domain.usecase.ChangeBookSourceUseCase
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
import io.legado.app.domain.usecase.ClearBookCacheUseCase
import io.legado.app.domain.usecase.DeleteBooksUseCase
import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase
import io.legado.app.domain.usecase.GetChapterContentUseCase
import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase
@@ -85,6 +91,7 @@ import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel
import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel
import io.legado.app.ui.book.changesource.ChangeChapterSourceViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.group.GroupViewModel
import io.legado.app.ui.book.import.local.ImportBookViewModel
@@ -147,6 +154,10 @@ val appModule = module {
singleOf(::SearchContentRepository)
singleOf(::RemoteBookRepository)
singleOf(::SettingsRepository)
singleOf(::ReadSettingsRepository)
singleOf(::ReadAloudSettingsRepository)
singleOf(::ReadBookStyleConfigRepository)
singleOf(::MangaSettingsRepository)
singleOf(::LocalPreferencesRepository)
singleOf(::ExploreBooksUseCase)
singleOf(::ExploreKindUiUseCase)
@@ -194,6 +205,8 @@ val appModule = module {
single<SearchRepository> { get<SearchRepositoryImpl>() }
single<BookSearchGateway> { get<SearchRepositoryImpl>() }
singleOf(::SearchBooksUseCase)
singleOf(::ChangeSourceSearchUseCase)
singleOf(::GetChapterContentUseCase)
single<LlmGateway> { LlmTranslateRepositoryImpl() }
single<DictionaryGateway> { DictionaryRepositoryImpl() }
singleOf(::TranslateChapterUseCase)
@@ -252,12 +265,16 @@ val appModule = module {
application = get(),
getReadingProgressUseCase = get(),
uploadReadingProgressUseCase = get(),
translateChapterUseCase = get()
translateChapterUseCase = get(),
readSettingsRepository = get(),
readBookStyleConfigRepository = get(),
readAloudSettingsRepository = get()
)
}
viewModelOf(::ChangeCoverViewModel)
viewModelOf(::ChangeBookSourceComposeViewModel)
viewModelOf(::ChangeBookSourceViewModel)
viewModelOf(::ChangeChapterSourceViewModel)
viewModelOf(::ExploreViewModel)
viewModelOf(::RssViewModel)
viewModelOf(::SearchViewModel)
@@ -0,0 +1,276 @@
package io.legado.app.domain.usecase
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.gateway.BookSearchGateway
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.primaryStr
import io.legado.app.help.book.releaseHtmlData
import io.legado.app.help.config.AppConfig
import io.legado.app.help.source.SourceHelp
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.changesource.ObservableSourceConfig
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.utils.internString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withTimeout
import java.util.concurrent.ConcurrentHashMap
sealed interface ChangeSourceSearchEvent {
data object Started : ChangeSourceSearchEvent
data class Progress(
val processedSources: Int,
val totalSources: Int,
val resultCount: Int,
val sourceName: String,
) : ChangeSourceSearchEvent
data class Result(val searchBook: SearchBook) : ChangeSourceSearchEvent
data class Finished(val isEmpty: Boolean) : ChangeSourceSearchEvent
}
class ChangeSourceSearchUseCase(
private val gateway: BookSearchGateway,
) {
private val threadCount = OtherConfig.threadCount
private val contentProcessor by lazy {
// ContentProcessor needs the old book - will be set before search
null as ContentProcessor?
}
// Shared state for TOC cache
private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
private val bookMap = ConcurrentHashMap<String, Book>()
private var tocMapChapterCount = 0
fun search(
name: String,
author: String,
scope: io.legado.app.ui.book.search.SearchScope,
oldBook: Book,
fromReadBookActivity: Boolean,
): Flow<ChangeSourceSearchEvent> = flow {
val contentProcessor = ContentProcessor.get(oldBook)
val bookSourceParts = scope.getBookSourceParts()
if (bookSourceParts.isEmpty()) {
throw io.legado.app.exception.NoStackTraceException("启用书源为空")
}
tocMap.clear()
bookMap.clear()
tocMapChapterCount = 0
emit(ChangeSourceSearchEvent.Started)
var processedSources = 0
val totalSources = bookSourceParts.size
for (bs in bookSourceParts) {
currentCoroutineContext().ensureActive()
val source = bs.getBookSource() ?: continue
try {
withTimeout(60000L) {
searchSource(
source, name, author, oldBook, fromReadBookActivity,
contentProcessor
)
}.forEach { searchBook ->
emit(ChangeSourceSearchEvent.Result(searchBook))
}
} catch (_: Throwable) {
currentCoroutineContext().ensureActive()
}
processedSources++
emit(
ChangeSourceSearchEvent.Progress(
processedSources = processedSources,
totalSources = totalSources,
resultCount = 0,
sourceName = source.bookSourceName,
)
)
}
emit(ChangeSourceSearchEvent.Finished(isEmpty = true))
}.flowOn(Dispatchers.IO)
private suspend fun searchSource(
source: BookSource,
name: String,
author: String,
oldBook: Book,
fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor,
): List<SearchBook> {
val checkAuthor = AppConfig.changeSourceCheckAuthor
val loadInfo = AppConfig.changeSourceLoadInfo
val loadToc = AppConfig.changeSourceLoadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
filter = { fName, fAuthor, _ ->
fName == name && (!checkAuthor || fAuthor.contains(author))
}
)
val processedBooks = mutableListOf<SearchBook>()
for (searchBook in resultBooks) {
currentCoroutineContext().ensureActive()
when {
loadInfo || loadToc || loadWordCount -> {
val book = searchBook.toBook()
try {
loadBookInfo(
source,
book,
loadToc,
loadWordCount,
oldBook,
fromReadBookActivity,
contentProcessor
)
val processedSearchBook = book.toSearchBook()
processedBooks.add(processedSearchBook)
} catch (e: Throwable) {
if (e is CancellationException) throw e
processedBooks.add(searchBook)
}
}
else -> {
processedBooks.add(searchBook)
}
}
}
return processedBooks
}
private suspend fun loadBookInfo(
source: BookSource,
book: Book,
loadToc: Boolean,
loadWordCount: Boolean,
oldBook: Book,
fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor,
) {
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
if (loadToc || loadWordCount) {
loadBookToc(
source,
book,
loadWordCount,
oldBook,
fromReadBookActivity,
contentProcessor
)
}
}
private suspend fun loadBookToc(
source: BookSource,
book: Book,
loadWordCount: Boolean,
oldBook: Book,
fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor,
) {
val chapters = WebBook.getChapterListAwait(source, book).getOrThrow()
for (chapter in chapters) {
chapter.internString()
}
if (tocMapChapterCount < 30000) {
tocMapChapterCount += chapters.size
tocMap[book.primaryStr()] = chapters
}
bookMap[book.primaryStr()] = book
book.releaseHtmlData()
if (loadWordCount) {
loadBookWordCount(
source,
book,
chapters,
oldBook,
fromReadBookActivity,
contentProcessor
)
}
}
private suspend fun loadBookWordCount(
source: BookSource,
book: Book,
chapters: List<BookChapter>,
oldBook: Book,
fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor,
) {
if (chapters.isEmpty()) return
val chapterIndex = if (fromReadBookActivity) {
BookHelp.getDurChapter(oldBook, chapters)
} else {
chapters.lastIndex
}
if (chapterIndex !in chapters.indices) return
val bookChapter = chapters[chapterIndex]
var title = bookChapter.title.trim()
if (title.length > 20) {
title = title.substring(0, 20) + ""
}
val startTime = System.currentTimeMillis()
try {
val nextChapterUrl = chapters.getOrNull(chapterIndex + 1)?.url
var content = WebBook.getContentAwait(source, book, bookChapter, nextChapterUrl, false)
content = contentProcessor.getContent(oldBook, bookChapter, content, false).toString()
val len = content.length
val endTime = System.currentTimeMillis()
book.toSearchBook().apply {
chapterWordCountText = "[${chapterIndex + 1}] ${title}\n字数:${len}"
chapterWordCount = len
respondTime = (endTime - startTime).toInt()
}
} catch (t: Throwable) {
if (t is CancellationException) throw t
val endTime = System.currentTimeMillis()
book.toSearchBook().apply {
chapterWordCountText =
"[${chapterIndex + 1}] ${title}\n获取字数失败:${t.localizedMessage}"
chapterWordCount = -1
respondTime = (endTime - startTime).toInt()
}
}
}
// Source management
fun topSource(searchBook: SearchBook) {
ObservableSourceConfig.setBookScore(searchBook, 1)
}
fun bottomSource(searchBook: SearchBook) {
ObservableSourceConfig.setBookScore(searchBook, 0)
}
fun disableSource(searchBook: SearchBook) {
io.legado.app.data.appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source ->
source.enabled = false
io.legado.app.data.appDb.bookSourceDao.update(source)
}
}
fun deleteSource(searchBook: SearchBook) {
SourceHelp.deleteBookSource(searchBook.origin)
io.legado.app.data.appDb.searchBookDao.delete(searchBook)
}
}
@@ -0,0 +1,48 @@
package io.legado.app.domain.usecase
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookSourceDao
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
class GetChapterContentUseCase(
private val bookSourceDao: BookSourceDao,
private val bookChapterDao: BookChapterDao,
) {
/**
* Get TOC for a book. If tocUrl is empty, fetches book info first.
*/
suspend fun getToc(book: Book): Pair<List<BookChapter>, BookSource> {
val source = bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
return Pair(toc, source)
}
/**
* Get content for a specific chapter.
*/
suspend fun getContent(
book: Book,
chapter: BookChapter,
nextChapterUrl: String?,
): String {
val bookSource = bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
return WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false)
}
/**
* Find the chapter index in a new TOC matching the current chapter.
*/
fun getDurChapterIndex(chapterIndex: Int, chapterTitle: String, toc: List<BookChapter>): Int {
return io.legado.app.help.book.BookHelp.getDurChapter(chapterIndex, chapterTitle, toc)
}
}
@@ -8,7 +8,7 @@ import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.RssSource
import io.legado.app.data.entities.TxtTocRule
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover
@@ -70,12 +70,12 @@ object DefaultData {
GSON.fromJsonArray<TxtTocRule>(json).getOrNull() ?: emptyList()
}
val themeConfigs: List<OldThemeConfig.Config> by lazy {
val themeConfigs: List<ThemeConfigStore.Config> by lazy {
val json = String(
appCtx.assets.open("defaultData${File.separator}${OldThemeConfig.configFileName}")
appCtx.assets.open("defaultData${File.separator}${ThemeConfigStore.configFileName}")
.readBytes()
)
GSON.fromJsonArray<OldThemeConfig.Config>(json).getOrNull() ?: emptyList()
GSON.fromJsonArray<ThemeConfigStore.Config>(json).getOrNull() ?: emptyList()
}
val rssSources: List<RssSource> by lazy {
@@ -15,7 +15,7 @@ import io.legado.app.constant.AppPattern
import io.legado.app.data.entities.BaseSource
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.http.BackstageWebView
import io.legado.app.help.http.CookieManager.cookieJarHeader
@@ -1204,12 +1204,12 @@ interface JsExtensions : JsEncodeUtils {
*/
@JavascriptInterface
fun getThemeConfig(): String {
val themeConfig = OldThemeConfig.getDurConfig(appCtx)
val themeConfig = ThemeConfigStore.getDurConfig(appCtx)
return GSON.toJson(themeConfig)
}
fun getThemeConfigMap(): Map<String, Any?> {
return OldThemeConfig.getDurConfig(appCtx).toMap()
return ThemeConfigStore.getDurConfig(appCtx).toMap()
}
}
@@ -5,6 +5,7 @@ import android.os.Build
import io.legado.app.BuildConfig
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.repository.ReadPreferences
import io.legado.app.ui.book.manga.config.MangaScrollMode
import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
import io.legado.app.utils.getPrefBoolean
@@ -64,8 +65,134 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
var adaptSpecialStyle = appCtx.getPrefBoolean(PreferKey.adaptSpecialStyle, true)
var useUnderline = appCtx.getPrefBoolean(PreferKey.useUnderline, false)
private var screenOrientationValue = appCtx.getPrefString(PreferKey.screenOrientation) ?: "0"
private var noAnimScrollPageValue = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false)
private var tocUiUseReplaceValue = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace)
private var tocCountWordsValue = appCtx.getPrefBoolean(PreferKey.tocCountWords, true)
private var autoChangeSourceValue = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true)
private var clickImgWayValue = appCtx.getPrefString(PreferKey.clickImgWay, "2") ?: "2"
private var doublePageHorizontalValue = appCtx.getPrefString(PreferKey.doublePageHorizontal, "0") ?: "0"
private var progressBarBehaviorValue = appCtx.getPrefString(PreferKey.progressBarBehavior, "page") ?: "page"
private var keyPageOnLongPressValue = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false)
private var volumeKeyPageValue = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true)
private var volumeKeyPageOnPlayValue = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true)
private var mouseWheelPageValue = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true)
private var paddingDisplayCutoutsValue = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false)
private var pageTouchSlopValue = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0)
private var showReadTitleBarAdditionValue =
appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true)
private var titleBarModeValue = appCtx.getPrefString(PreferKey.titleBarMode, "1") ?: "1"
private var menuAlphaValue = appCtx.getPrefInt(PreferKey.menuAlpha, 100)
private var readSliderModeValue = appCtx.getPrefString(PreferKey.readSliderMode, "0") ?: "0"
private var readBarStyleFollowPageValue =
appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
private var readBarStyleValue = appCtx.getPrefInt(PreferKey.readBarStyle, 0)
private var defaultSourceChangeAllValue =
appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true)
private var sliderVibratorValue = appCtx.getPrefBoolean(PreferKey.sliderVibrator, false)
private var selectVibratorValue = appCtx.getPrefBoolean(PreferKey.selectVibrator, false)
fun syncReadPreferences(preferences: ReadPreferences) {
optimizeRender = CanvasRecorderFactory.isSupport && preferences.optimizeRender
adaptSpecialStyle = preferences.adaptSpecialStyle
useUnderline = preferences.useUnderline
clickActionTL = preferences.clickActionTL
clickActionTC = preferences.clickActionTC
clickActionTR = preferences.clickActionTR
clickActionML = preferences.clickActionML
clickActionMC = preferences.clickActionMC
clickActionMR = preferences.clickActionMR
clickActionBL = preferences.clickActionBL
clickActionBC = preferences.clickActionBC
clickActionBR = preferences.clickActionBR
screenOrientationValue = preferences.screenOrientation
noAnimScrollPageValue = preferences.noAnimScrollPage
tocUiUseReplaceValue = preferences.tocUiUseReplace
tocCountWordsValue = preferences.tocCountWords
autoChangeSourceValue = preferences.autoChangeSource
clickImgWayValue = preferences.clickImgWay
doublePageHorizontalValue = preferences.doubleHorizontalPage
progressBarBehaviorValue = preferences.progressBarBehavior
keyPageOnLongPressValue = preferences.keyPageOnLongPress
volumeKeyPageValue = preferences.volumeKeyPage
volumeKeyPageOnPlayValue = preferences.volumeKeyPageOnPlay
mouseWheelPageValue = preferences.mouseWheelPage
paddingDisplayCutoutsValue = preferences.paddingDisplayCutouts
pageTouchSlopValue = preferences.pageTouchSlop
showReadTitleBarAdditionValue = preferences.showReadTitleAddition
titleBarModeValue = preferences.titleBarMode
menuAlphaValue = preferences.menuAlpha
readSliderModeValue = preferences.readSliderMode
readBarStyleFollowPageValue = preferences.readBarStyleFollowPage
readBarStyleValue = preferences.readBarStyle
defaultSourceChangeAllValue = preferences.defaultSourceChangeAll
sliderVibratorValue = preferences.sliderVibrator
selectVibratorValue = preferences.selectVibrator
}
fun updateReadBarStyleCache(value: Int) {
readBarStyleValue = value.coerceIn(0, 2)
}
private fun syncReadPreferenceFromSharedPreferences(key: String?) {
when (key) {
PreferKey.optimizeRender -> optimizeRender = CanvasRecorderFactory.isSupport &&
appCtx.getPrefBoolean(PreferKey.optimizeRender, false)
PreferKey.adaptSpecialStyle -> adaptSpecialStyle =
appCtx.getPrefBoolean(PreferKey.adaptSpecialStyle, true)
PreferKey.useUnderline -> useUnderline =
appCtx.getPrefBoolean(PreferKey.useUnderline, false)
PreferKey.screenOrientation -> screenOrientationValue =
appCtx.getPrefString(PreferKey.screenOrientation) ?: "0"
PreferKey.noAnimScrollPage -> noAnimScrollPageValue =
appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false)
PreferKey.tocUiUseReplace -> tocUiUseReplaceValue =
appCtx.getPrefBoolean(PreferKey.tocUiUseReplace)
PreferKey.tocCountWords -> tocCountWordsValue =
appCtx.getPrefBoolean(PreferKey.tocCountWords, true)
PreferKey.autoChangeSource -> autoChangeSourceValue =
appCtx.getPrefBoolean(PreferKey.autoChangeSource, true)
PreferKey.clickImgWay -> clickImgWayValue =
appCtx.getPrefString(PreferKey.clickImgWay, "2") ?: "2"
PreferKey.doublePageHorizontal -> doublePageHorizontalValue =
appCtx.getPrefString(PreferKey.doublePageHorizontal, "0") ?: "0"
PreferKey.progressBarBehavior -> progressBarBehaviorValue =
appCtx.getPrefString(PreferKey.progressBarBehavior, "page") ?: "page"
PreferKey.keyPageOnLongPress -> keyPageOnLongPressValue =
appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false)
PreferKey.volumeKeyPage -> volumeKeyPageValue =
appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true)
PreferKey.volumeKeyPageOnPlay -> volumeKeyPageOnPlayValue =
appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true)
PreferKey.mouseWheelPage -> mouseWheelPageValue =
appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true)
PreferKey.paddingDisplayCutouts -> paddingDisplayCutoutsValue =
appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false)
PreferKey.pageTouchSlop -> pageTouchSlopValue =
appCtx.getPrefInt(PreferKey.pageTouchSlop, 0)
PreferKey.showReadTitleAddition -> showReadTitleBarAdditionValue =
appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true)
PreferKey.titleBarMode -> titleBarModeValue =
appCtx.getPrefString(PreferKey.titleBarMode, "1") ?: "1"
PreferKey.menuAlpha -> menuAlphaValue = appCtx.getPrefInt(PreferKey.menuAlpha, 100)
PreferKey.readSliderMode -> readSliderModeValue =
appCtx.getPrefString(PreferKey.readSliderMode, "0") ?: "0"
PreferKey.readBarStyleFollowPage -> readBarStyleFollowPageValue =
appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
PreferKey.readBarStyle -> readBarStyleValue =
appCtx.getPrefInt(PreferKey.readBarStyle, 0)
PreferKey.defaultSourceChangeAll -> defaultSourceChangeAllValue =
appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true)
PreferKey.sliderVibrator -> sliderVibratorValue =
appCtx.getPrefBoolean(PreferKey.sliderVibrator, false)
PreferKey.selectVibrator -> selectVibratorValue =
appCtx.getPrefBoolean(PreferKey.selectVibrator, false)
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
syncReadPreferenceFromSharedPreferences(key)
when (key) {
PreferKey.adaptSpecialStyle -> adaptSpecialStyle =
@@ -230,7 +357,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
// get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true)
val screenOrientation: String?
get() = appCtx.getPrefString(PreferKey.screenOrientation)
get() = screenOrientationValue
var bookGroupStyle: Int
get() = appCtx.getPrefInt(PreferKey.bookGroupStyle, 0)
@@ -351,7 +478,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
val noAnimScrollPage: Boolean
get() = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false)
get() = noAnimScrollPageValue
const val defaultSpeechRate = 5
@@ -468,14 +595,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var tocUiUseReplace: Boolean
get() = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace)
get() = tocUiUseReplaceValue
set(value) {
tocUiUseReplaceValue = value
appCtx.putPrefBoolean(PreferKey.tocUiUseReplace, value)
}
var tocCountWords: Boolean
get() = appCtx.getPrefBoolean(PreferKey.tocCountWords, true)
get() = tocCountWordsValue
set(value) {
tocCountWordsValue = value
appCtx.putPrefBoolean(PreferKey.tocCountWords, value)
}
@@ -486,7 +615,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
val autoChangeSource: Boolean
get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true)
get() = autoChangeSourceValue
var changeSourceLoadInfo: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo)
@@ -545,7 +674,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
val clickImgWay: String?
get() = appCtx.getPrefString(PreferKey.clickImgWay)
get() = clickImgWayValue
var preDownloadNum
get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10)
set(value) {
@@ -590,25 +719,25 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false)
val doublePageHorizontal: String?
get() = appCtx.getPrefString(PreferKey.doublePageHorizontal)
get() = doublePageHorizontalValue
val progressBarBehavior: String?
get() = appCtx.getPrefString(PreferKey.progressBarBehavior, "page")
get() = progressBarBehaviorValue
val keyPageOnLongPress
get() = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false)
get() = keyPageOnLongPressValue
val volumeKeyPage
get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true)
get() = volumeKeyPageValue
val volumeKeyPageOnPlay
get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true)
get() = volumeKeyPageOnPlayValue
val mouseWheelPage
get() = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true)
get() = mouseWheelPageValue
val paddingDisplayCutouts
get() = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false)
get() = paddingDisplayCutoutsValue
var searchScope: String
get() = appCtx.getPrefString("searchScope") ?: ""
@@ -623,8 +752,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var pageTouchSlop: Int
get() = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0)
get() = pageTouchSlopValue
set(value) {
pageTouchSlopValue = value
appCtx.putPrefInt(PreferKey.pageTouchSlop, value)
}
@@ -666,21 +796,24 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var showReadTitleBarAddition: Boolean
get() = appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true)
get() = showReadTitleBarAdditionValue
set(value) {
showReadTitleBarAdditionValue = value
appCtx.putPrefBoolean(PreferKey.showReadTitleAddition, value)
}
var readBarStyleFollowPage: Boolean
get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false)
get() = readBarStyleFollowPageValue
set(value) {
readBarStyleFollowPageValue = value
appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value)
}
var readBarStyle: Int
get() = appCtx.getPrefInt(PreferKey.readBarStyle, 0)
get() = readBarStyleValue
set(value) {
appCtx.putPrefInt(PreferKey.readBarStyle, value)
readBarStyleValue = value.coerceIn(0, 2)
appCtx.putPrefInt(PreferKey.readBarStyle, readBarStyleValue)
}
var sourceEditMaxLine: Int
@@ -757,8 +890,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var titleBarMode
get() = appCtx.getPrefString(PreferKey.titleBarMode, "1")
get() = titleBarModeValue
set(value) {
titleBarModeValue = value
appCtx.putPrefString(PreferKey.titleBarMode, value)
}
@@ -903,14 +1037,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.enableBlur, value)
}
var menuAlpha: Int
get() = appCtx.getPrefInt(PreferKey.menuAlpha, 100)
get() = menuAlphaValue
set(value) {
menuAlphaValue = value
appCtx.putPrefInt(PreferKey.menuAlpha, value)
}
var readSliderMode
get() = appCtx.getPrefString(PreferKey.readSliderMode, "0")
get() = readSliderModeValue
set(value) {
readSliderModeValue = value
appCtx.putPrefString(PreferKey.readSliderMode, value)
}
@@ -945,20 +1081,23 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var defaultSourceChangeAll: Boolean
get() = appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true)
get() = defaultSourceChangeAllValue
set(value) {
defaultSourceChangeAllValue = value
appCtx.putPrefBoolean(PreferKey.defaultSourceChangeAll, value)
}
var sliderVibrator: Boolean
get() = appCtx.getPrefBoolean(PreferKey.sliderVibrator, false)
get() = sliderVibratorValue
set(value) {
sliderVibratorValue = value
appCtx.putPrefBoolean(PreferKey.sliderVibrator, value)
}
var selectVibrator: Boolean
get() = appCtx.getPrefBoolean(PreferKey.selectVibrator, false)
get() = selectVibratorValue
set(value) {
selectVibratorValue = value
appCtx.putPrefBoolean(PreferKey.selectVibrator, value)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
package io.legado.app.help.config
import android.graphics.Color
import android.graphics.drawable.Drawable
import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.toColorInt
import com.google.android.material.color.MaterialColors
import io.legado.app.utils.BitmapUtils
import io.legado.app.utils.FileUtils
import io.legado.app.utils.externalFiles
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.resizeAndRecycle
import splitties.init.appCtx
import java.io.File
object ReadStyleResolver {
enum class ReadStyleMode {
Day,
Night,
EInk
}
data class ReadBackground(
val type: Int,
val value: String
)
fun currentMode(): ReadStyleMode {
return when {
AppConfig.isEInkMode -> ReadStyleMode.EInk
AppConfig.isNightTheme -> ReadStyleMode.Night
else -> ReadStyleMode.Day
}
}
fun isNightTheme(): Boolean {
return AppConfig.isNightTheme
}
fun setCurrentBackground(
config: ReadBookConfig.Config,
bgType: Int,
bg: String
) {
when (currentMode()) {
ReadStyleMode.EInk -> {
config.bgTypeEInk = bgType
config.bgStrEInk = bg
}
ReadStyleMode.Night -> {
config.bgTypeNight = bgType
config.bgStrNight = bg
}
ReadStyleMode.Day -> {
config.bgType = bgType
config.bgStr = bg
}
}
}
fun currentBackground(config: ReadBookConfig.Config): ReadBackground {
return when (currentMode()) {
ReadStyleMode.EInk -> ReadBackground(config.bgTypeEInk, config.bgStrEInk)
ReadStyleMode.Night -> ReadBackground(config.bgTypeNight, config.bgStrNight)
ReadStyleMode.Day -> ReadBackground(config.bgType, config.bgStr)
}
}
fun backgroundPath(config: ReadBookConfig.Config, bgIndex: Int): String? {
val bgType = when (bgIndex) {
0 -> config.bgType
1 -> config.bgTypeNight
2 -> config.bgTypeEInk
else -> error("unknown bgIndex: $bgIndex")
}
if (bgType != 2) {
return null
}
val bgStr = when (bgIndex) {
0 -> config.bgStr
1 -> config.bgStrNight
2 -> config.bgStrEInk
else -> error("unknown bgIndex: $bgIndex")
}
return if (bgStr.contains(File.separator)) {
bgStr
} else {
FileUtils.getPath(appCtx.externalFiles, "bg", bgStr)
}
}
fun currentBackgroundDrawable(
config: ReadBookConfig.Config,
width: Int,
height: Int
): Drawable {
if (width == 0 || height == 0) {
return fallbackBackground()
}
var bgDrawable: Drawable? = null
val resources = appCtx.resources
val background = currentBackground(config)
try {
bgDrawable = when (background.type) {
0 -> background.value.toColorInt().toDrawable()
1 -> {
val path = "bg" + File.separator + background.value
val bitmap = BitmapUtils.decodeAssetsBitmap(appCtx, path, width, height)
bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources)
}
else -> {
val path = background.value.let {
if (it.contains(File.separator)) it
else FileUtils.getPath(appCtx.externalFiles, "bg", background.value)
}
val bitmap = BitmapUtils.decodeBitmap(path, width, height)
bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources)
}
}
} catch (e: OutOfMemoryError) {
e.printOnDebug()
} catch (e: Exception) {
e.printOnDebug()
}
return bgDrawable ?: fallbackBackground()
}
private fun fallbackBackground(): Drawable {
val fallbackColor = MaterialColors.getColor(
appCtx,
com.google.android.material.R.attr.colorSurface,
Color.WHITE
)
return fallbackColor.toDrawable()
}
}
@@ -1,120 +0,0 @@
package io.legado.app.help.config
import android.content.Context
import io.legado.app.R
import splitties.init.appCtx
@Suppress("ConstPropertyName")
object ReadTipConfig {
const val none = 0
const val chapterTitle = 1
const val time = 2
const val battery = 3
const val batteryPercentage = 10
const val page = 4
const val totalProgress = 5
const val pageAndTotal = 6
const val bookName = 7
const val timeBattery = 8
const val timeBatteryPercentage = 9
const val totalProgress1 = 11
const val chapterTitleArrow = 12
const val batteryInside = 13
const val batteryIcon = 14
const val batteryClassic = 15
const val timeBatteryClassic = 16
const val chapterTitleArrowClassic = 17
val tipValues = arrayOf(
none, bookName, chapterTitle, chapterTitleArrow, chapterTitleArrowClassic, time, battery, batteryClassic, batteryInside, batteryIcon, batteryPercentage, page,
totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryClassic, timeBatteryPercentage
)
val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList()
val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList()
val tipDividerColorNames
get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList()
var tipHeaderLeft: Int
get() = ReadBookConfig.config.tipHeaderLeft
set(value) {
ReadBookConfig.config.tipHeaderLeft = value
}
var tipHeaderMiddle: Int
get() = ReadBookConfig.config.tipHeaderMiddle
set(value) {
ReadBookConfig.config.tipHeaderMiddle = value
}
var tipHeaderRight: Int
get() = ReadBookConfig.config.tipHeaderRight
set(value) {
ReadBookConfig.config.tipHeaderRight = value
}
var tipFooterLeft: Int
get() = ReadBookConfig.config.tipFooterLeft
set(value) {
ReadBookConfig.config.tipFooterLeft = value
}
var tipFooterMiddle: Int
get() = ReadBookConfig.config.tipFooterMiddle
set(value) {
ReadBookConfig.config.tipFooterMiddle = value
}
var tipFooterRight: Int
get() = ReadBookConfig.config.tipFooterRight
set(value) {
ReadBookConfig.config.tipFooterRight = value
}
var headerMode: Int
get() = ReadBookConfig.config.headerMode
set(value) {
ReadBookConfig.config.headerMode = value
}
var footerMode: Int
get() = ReadBookConfig.config.footerMode
set(value) {
ReadBookConfig.config.footerMode = value
}
var tipHeaderColor: Int
get() = ReadBookConfig.config.tipHeaderColor
set(value) {
ReadBookConfig.config.tipHeaderColor = value
}
var tipFooterColor: Int
get() = ReadBookConfig.config.tipFooterColor
set(value) {
ReadBookConfig.config.tipFooterColor = value
}
var tipDividerColor: Int
get() = ReadBookConfig.config.tipDividerColor
set(value) {
ReadBookConfig.config.tipDividerColor = value
}
fun getHeaderModes(context: Context): LinkedHashMap<Int, String> {
return linkedMapOf(
Pair(0, context.getString(R.string.hide_when_status_bar_show)),
Pair(1, context.getString(R.string.show)),
Pair(2, context.getString(R.string.hide))
)
}
fun getFooterModes(context: Context): LinkedHashMap<Int, String> {
return linkedMapOf(
Pair(0, context.getString(R.string.show)),
Pair(1, context.getString(R.string.hide))
)
}
}
@@ -4,15 +4,13 @@ import android.content.Context
import android.graphics.Bitmap
import android.util.DisplayMetrics
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.graphics.toColorInt
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.constant.Theme
import io.legado.app.help.DefaultData
import io.legado.app.model.BookCover
import io.legado.app.utils.BitmapUtils
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
@@ -26,13 +24,12 @@ import io.legado.app.utils.getPrefString
import io.legado.app.utils.hexString
import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.putPrefInt
import io.legado.app.utils.stackBlur
import splitties.init.appCtx
import java.io.File
@Keep
object OldThemeConfig {
object ThemeConfigStore {
const val configFileName = "themeConfig.json"
val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName)
@@ -46,13 +43,8 @@ object OldThemeConfig {
else -> Theme.Light
}
fun isDarkTheme(): Boolean {
return getTheme() == Theme.Dark
}
fun applyDayNight(context: Context) {
initNightMode()
BookCover.upDefaultCover()
postEvent(EventBus.RECREATE, "")
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
@@ -62,17 +54,7 @@ object OldThemeConfig {
}
private fun initNightMode() {
when (appCtx.getPrefString(PreferKey.themeMode, "0")) {
"1" -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
}
"2" -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
else -> {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
}
}
ThemeConfig.initNightMode()
}
fun getBgImage(context: Context, metrics: DisplayMetrics): Bitmap? {
@@ -110,11 +92,6 @@ object OldThemeConfig {
FileUtils.createFileIfNotExist(configFilePath).writeText(json)
}
fun delConfig(index: Int) {
configList.removeAt(index)
save()
}
fun addConfig(json: String): Boolean {
GSON.fromJsonObject<Config>(json.trim { it < ' ' }).getOrNull()
?.let {
@@ -165,33 +142,8 @@ object OldThemeConfig {
return null
}
fun applyConfig(context: Context, config: Config) {
try {
val primary = config.primaryColor.toColorInt()
if (config.isNightTheme) {
context.putPrefInt(PreferKey.cNPrimary, primary)
} else {
context.putPrefInt(PreferKey.cPrimary, primary)
}
AppConfig.isNightTheme = config.isNightTheme
applyDayNight(context)
} catch (e: Exception) {
AppLog.put("设置主题出错\n$e", e, true)
}
}
fun saveDayTheme(context: Context, name: String) {
val config = getDayTheme(context, name)
addConfig(config)
}
fun saveNightTheme(context: Context, name: String) {
val config = getNightTheme(context, name)
addConfig(config)
}
/**
* 更新主题
* 清理无用背景图片
*/
fun clearBg() {
val bgImagePath = appCtx.getPrefString(PreferKey.bgImage)
@@ -13,7 +13,7 @@ import io.legado.app.help.AppWebDav
import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover
@@ -77,7 +77,7 @@ object Backup {
DirectLinkUpload.ruleFileName,
ReadBookConfig.configFileName,
ReadBookConfig.shareConfigFileName,
OldThemeConfig.configFileName,
ThemeConfigStore.configFileName,
BookCover.configFileName,
"config.xml"
)
@@ -164,8 +164,8 @@ object Backup {
FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName)
.writeText(it)
}
GSON.toJson(OldThemeConfig.configList).let {
FileUtils.createFileIfNotExist(backupPath + File.separator + OldThemeConfig.configFileName)
GSON.toJson(ThemeConfigStore.configList).let {
FileUtils.createFileIfNotExist(backupPath + File.separator + ThemeConfigStore.configFileName)
.writeText(it)
}
DirectLinkUpload.getConfig()?.let {
@@ -34,7 +34,7 @@ import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.upType
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.model.BookCover
import io.legado.app.model.localBook.LocalBook
@@ -275,12 +275,12 @@ object Restore : KoinComponent {
}
//恢复主题配置
if (!BackupConfig.ignoreThemeConfig) {
File(path, OldThemeConfig.configFileName).takeIf {
File(path, ThemeConfigStore.configFileName).takeIf {
it.exists()
}?.runCatching {
FileUtils.delete(OldThemeConfig.configFilePath)
copyTo(File(OldThemeConfig.configFilePath))
OldThemeConfig.upConfig()
FileUtils.delete(ThemeConfigStore.configFilePath)
copyTo(File(ThemeConfigStore.configFilePath))
ThemeConfigStore.upConfig()
}?.onFailure {
AppLog.put("恢复主题出错\n${it.localizedMessage}", it)
}
@@ -341,7 +341,7 @@ object Restore : KoinComponent {
if (!BuildConfig.DEBUG) {
LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon))
}
OldThemeConfig.applyDayNight(appCtx)
ThemeConfigStore.applyDayNight(appCtx)
}
}
@@ -7,7 +7,7 @@ import android.util.AttributeSet
import androidx.preference.PreferenceViewHolder
import com.google.android.material.button.MaterialButtonToggleGroup
import io.legado.app.R
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
class ThemeModePreference(context: Context, attrs: AttributeSet) : Preference(context, attrs) {
@@ -59,7 +59,7 @@ class ThemeModePreference(context: Context, attrs: AttributeSet) : Preference(co
persistString(newValue)
callChangeListener(newValue)
Handler(Looper.getMainLooper()).postDelayed({
OldThemeConfig.applyDayNight(context)
ThemeConfigStore.applyDayNight(context)
}, 300)
}
}
@@ -63,9 +63,6 @@ object BookCover {
}.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null))
}
// 兼容旧代码,空实现
fun upDefaultCover() {}
fun getRandomDefaultPath(
seed: Any? = null,
isNight: Boolean = AppConfig.isNightTheme
@@ -14,7 +14,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.service.AudioPlayService
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.LogUtils
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.postEvent
@@ -94,7 +94,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
// break
}
LifecycleHelp.isExistActivity(ReadBookActivity::class.java) ->
MainActivity.hasActiveReadBookRoute ->
postEvent(EventBus.MEDIA_BUTTON, true)
LifecycleHelp.isExistActivity(AudioPlayActivity::class.java) ->
@@ -17,12 +17,10 @@ import splitties.systemservices.connectivityManager
class NetworkChangedListener(private val context: Context) {
var onNetworkChanged: (() -> Unit)? = null
private var registered = false
private val receiver: NetworkChangedReceiver? by lazy {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
NetworkChangedReceiver()
}
return@lazy null
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) NetworkChangedReceiver() else null
}
private val networkCallback: ConnectivityManager.NetworkCallback? by lazy {
@@ -38,25 +36,31 @@ class NetworkChangedListener(private val context: Context) {
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun register() {
if (registered) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
networkCallback?.let {
connectivityManager.registerDefaultNetworkCallback(it)
registered = true
}
} else {
receiver?.let {
context.registerReceiver(it, it.filter)
registered = true
}
}
}
fun unRegister() {
if (!registered) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
networkCallback?.let {
connectivityManager.unregisterNetworkCallback(it)
registered = false
}
} else {
receiver?.let {
context.unregisterReceiver(it)
registered = false
}
}
}
@@ -74,4 +78,4 @@ class NetworkChangedListener(private val context: Context) {
}
}
}
@@ -43,8 +43,8 @@ import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.receiver.MediaButtonReceiver
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.page.entities.TextChapter
import io.legado.app.ui.main.MainActivity
import io.legado.app.utils.LogUtils
import io.legado.app.utils.activityPendingIntent
import io.legado.app.utils.getPrefBoolean
@@ -639,7 +639,10 @@ abstract class BaseReadAloudService : BaseService(),
.setContentTitle(nTitle)
.setContentText(nSubtitle)
.setContentIntent(
activityPendingIntent<ReadBookActivity>("activity")
activityPendingIntent(
MainActivity.createReadBookIntent(this, readAloud = true),
"activity"
)
)
.setVibrate(null)
.setSound(null)
@@ -35,6 +35,8 @@ class InteractiveHighlight(
Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold)
private var startPosition = Offset.Zero
val pressProgress: Float get() = pressProgressAnimation.value
val offset: Offset get() = positionAnimation.value
@Language("AGSL")
private val shader = RuntimeShader(
@@ -14,7 +14,7 @@ import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.ItemSourceImportBinding
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.widget.dialog.CodeDialog
import io.legado.app.ui.widget.dialog.WaitDialog
@@ -126,7 +126,7 @@ class ImportThemeDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycl
}
inner class SourcesAdapter(context: Context) :
RecyclerAdapter<OldThemeConfig.Config, ItemSourceImportBinding>(context) {
RecyclerAdapter<ThemeConfigStore.Config, ItemSourceImportBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding {
return ItemSourceImportBinding.inflate(inflater, parent, false)
@@ -135,7 +135,7 @@ class ImportThemeDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycl
override fun convert(
holder: ItemViewHolder,
binding: ItemSourceImportBinding,
item: OldThemeConfig.Config,
item: ThemeConfigStore.Config,
payloads: MutableList<Any>
) {
binding.apply {
@@ -8,7 +8,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
@@ -28,8 +28,8 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) {
val errorLiveData = MutableLiveData<String>()
val successLiveData = MutableLiveData<Int>()
val allSources = arrayListOf<OldThemeConfig.Config>()
val checkSources = arrayListOf<OldThemeConfig.Config?>()
val allSources = arrayListOf<ThemeConfigStore.Config>()
val checkSources = arrayListOf<ThemeConfigStore.Config?>()
val selectStatus = arrayListOf<Boolean>()
val isSelectAll: Boolean
@@ -57,7 +57,7 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) {
execute {
selectStatus.forEachIndexed { index, b ->
if (b) {
OldThemeConfig.addConfig(allSources[index])
ThemeConfigStore.addConfig(allSources[index])
}
}
}.onFinally {
@@ -79,12 +79,12 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) {
private suspend fun importSourceAwait(text: String) {
when {
text.isJsonObject() -> {
GSON.fromJsonObject<OldThemeConfig.Config>(text).getOrThrow().let {
GSON.fromJsonObject<ThemeConfigStore.Config>(text).getOrThrow().let {
allSources.add(it)
}
}
text.isJsonArray() -> GSON.fromJsonArray<OldThemeConfig.Config>(text).getOrThrow()
text.isJsonArray() -> GSON.fromJsonArray<ThemeConfigStore.Config>(text).getOrThrow()
.let { items ->
allSources.addAll(items)
}
@@ -114,7 +114,7 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) {
private fun comparisonSource() {
execute {
allSources.forEach { config ->
val source = OldThemeConfig.configList.find {
val source = ThemeConfigStore.configList.find {
it.themeName == config.themeName
}
checkSources.add(source)
@@ -1,25 +1,35 @@
package io.legado.app.ui.book.changesource
import android.app.Application
import io.legado.app.data.appDb
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.usecase.ChangeSourceSearchEvent
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
import io.legado.app.domain.usecase.GetChapterContentUseCase
import io.legado.app.help.book.primaryStr
import io.legado.app.ui.book.search.SearchScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ChangeBookSourceComposeViewModel(
application: Application,
private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
private val getChapterContentUseCase: GetChapterContentUseCase,
private val searchRepository: SearchRepository,
) : ChangeBookSourceViewModel(application) {
) : ViewModel() {
// Public state for the sheet
val enabledGroups = searchRepository.enabledGroups
val enabledSources = searchRepository.enabledSources
val searchScope = SearchScope(ChangeSourceConfig.searchScope)
private val searchScope = SearchScope(ChangeSourceConfig.searchScope)
data class ScopeUiState(
val isAll: Boolean,
@@ -38,18 +48,164 @@ class ChangeBookSourceComposeViewModel(
)
val scopeUiState = _scopeUiState.asStateFlow()
val checkAuthor: Boolean
get() = ChangeSourceConfig.checkAuthor
private val _isSearching = MutableStateFlow(false)
val isSearching = _isSearching.asStateFlow()
val loadInfo: Boolean
get() = ChangeSourceConfig.loadInfo
private val _changeSourceProgress = MutableStateFlow(0 to "")
val changeSourceProgress = _changeSourceProgress.asStateFlow()
val loadToc: Boolean
get() = ChangeSourceConfig.loadToc
private val _searchDataFlow = MutableStateFlow<List<SearchBook>>(emptyList())
val searchDataFlow: StateFlow<List<SearchBook>> = _searchDataFlow.asStateFlow()
val loadWordCount: Boolean
get() = ChangeSourceConfig.loadWordCount
val totalSourceCount: Int
get() = searchResults.size
fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook()
// Options
val checkAuthor: Boolean get() = ChangeSourceConfig.checkAuthor
val loadInfo: Boolean get() = ChangeSourceConfig.loadInfo
val loadToc: Boolean get() = ChangeSourceConfig.loadToc
val loadWordCount: Boolean get() = ChangeSourceConfig.loadWordCount
// Internal state
private var searchJob: Job? = null
private var oldBook: Book? = null
private var screenKey: String = ""
private val searchResults = mutableListOf<SearchBook>()
private val bookMap = mutableMapOf<String, SearchBook>()
private val tocMap = mutableMapOf<String, List<BookChapter>>()
init {
viewModelScope.launch {
searchRepository.enabledGroups.collect { /* handled by sheet */ }
}
}
fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) {
this.oldBook = book
if (searchJob?.isActive != true) {
startSearch()
}
}
fun startSearch() {
val book = oldBook ?: return
stopSearch()
searchResults.clear()
bookMap.clear()
tocMap.clear()
_searchDataFlow.value = emptyList()
searchJob = viewModelScope.launch {
changeSourceSearchUseCase.search(
name = book.name,
author = book.author,
scope = SearchScope(ChangeSourceConfig.searchScope),
oldBook = book,
fromReadBookActivity = false,
).collect { event ->
when (event) {
is ChangeSourceSearchEvent.Started -> {
_isSearching.value = true
}
is ChangeSourceSearchEvent.Progress -> {
_changeSourceProgress.value = event.processedSources to event.sourceName
}
is ChangeSourceSearchEvent.Result -> {
searchResults.add(event.searchBook)
bookMap[event.searchBook.primaryStr()] = event.searchBook
filterResults()
}
is ChangeSourceSearchEvent.Finished -> {
_isSearching.value = false
}
}
}
}
}
fun startSearch(origin: String) {
// Reload a single source
viewModelScope.launch {
changeSourceSearchUseCase.topSource(
searchResults.find { it.origin == origin } ?: return@launch
)
startSearch()
}
}
fun stopSearch() {
searchJob?.cancel()
searchJob = null
_isSearching.value = false
}
fun screen(key: String?) {
screenKey = key?.trim() ?: ""
filterResults()
}
fun startOrStopSearch() {
if (searchJob?.isActive == true) {
stopSearch()
} else {
startSearch()
}
}
fun pause() {
// No-op for now
}
fun resume() {
// No-op for now
}
private fun filterResults() {
val filtered = if (screenKey.isEmpty()) {
searchResults.toList()
} else {
searchResults.filter {
it.name.contains(screenKey) || it.originName.contains(screenKey)
}
}
val sorted = filtered.sortedWith(
compareByDescending<SearchBook> { ObservableSourceConfig.getBookScore(it) }
.thenByDescending { io.legado.app.help.config.SourceConfig.getSourceScore(it.origin) }
.thenBy { it.originOrder }
)
_searchDataFlow.value = sorted
}
fun getToc(
book: Book,
onSuccess: (toc: List<BookChapter>, source: BookSource) -> Unit,
onError: (e: Throwable) -> Unit,
) {
viewModelScope.launch {
try {
val cachedToc = tocMap[book.primaryStr()]
if (cachedToc != null) {
val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
if (source != null) {
onSuccess(cachedToc, source)
return@launch
}
}
val (toc, source) = getChapterContentUseCase.getToc(book)
tocMap[book.primaryStr()] = toc
onSuccess(toc, source)
} catch (e: Exception) {
onError(e)
}
}
}
// Options
fun onCheckAuthorChange(enabled: Boolean) {
if (ChangeSourceConfig.checkAuthor == enabled) return
ChangeSourceConfig.checkAuthor = enabled
@@ -70,21 +226,67 @@ class ChangeBookSourceComposeViewModel(
if (ChangeSourceConfig.loadWordCount == enabled) return
ChangeSourceConfig.loadWordCount = enabled
if (enabled) {
onLoadWordCountChecked(true)
startSearch()
} else {
refresh()
}
}
fun bookScoreFlow(searchBook: SearchBook): StateFlow<Int> {
return ObservableSourceConfig.bookScoreFlow(searchBook)
fun refresh() {
searchResults.clear()
bookMap.clear()
startSearch()
}
// Source actions
fun topSource(searchBook: SearchBook) {
changeSourceSearchUseCase.topSource(searchBook)
refresh()
}
fun bottomSource(searchBook: SearchBook) {
changeSourceSearchUseCase.bottomSource(searchBook)
refresh()
}
fun disableSource(searchBook: SearchBook) {
changeSourceSearchUseCase.disableSource(searchBook)
searchResults.remove(searchBook)
filterResults()
}
fun del(searchBook: SearchBook) {
changeSourceSearchUseCase.deleteSource(searchBook)
searchResults.remove(searchBook)
filterResults()
}
fun autoChangeSource(
bookType: Int?,
onSuccess: (book: Book, toc: List<BookChapter>, source: BookSource) -> Unit,
) {
viewModelScope.launch {
val found = searchResults.firstOrNull { it.type == bookType }
if (found != null) {
try {
val (toc, source) = getChapterContentUseCase.getToc(found.toBook())
onSuccess(found.toBook(), toc, source)
} catch (_: Exception) {
}
}
}
}
// Score
fun bookScoreFlow(searchBook: SearchBook) = ObservableSourceConfig.bookScoreFlow(searchBook)
fun onBookScoreClick(searchBook: SearchBook) {
val currentScore = ObservableSourceConfig.getBookScore(searchBook)
setBookScore(searchBook, if (currentScore > 0) 0 else 1)
changeSourceSearchUseCase.topSource(searchBook)
ObservableSourceConfig.setBookScore(searchBook, if (currentScore > 0) 0 else 1)
}
// Scope
fun selectAllScope() {
searchScope.update("")
saveScope()
@@ -110,17 +312,15 @@ class ChangeBookSourceComposeViewModel(
} else {
mutableSetOf()
}
if (selectedUrls.contains(source.bookSourceUrl)) {
selectedUrls.remove(source.bookSourceUrl)
} else {
selectedUrls.add(source.bookSourceUrl)
}
if (selectedUrls.isEmpty()) {
searchScope.update("")
} else {
val selectedSources = appDb.bookSourceDao.allEnabledPart.filter {
val selectedSources = io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter {
selectedUrls.contains(it.bookSourceUrl)
}
searchScope.updateSources(selectedSources)
@@ -130,14 +330,12 @@ class ChangeBookSourceComposeViewModel(
private fun saveScope() {
ChangeSourceConfig.searchScope = searchScope.toString()
_scopeUiState.update {
ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
}
_scopeUiState.value = ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
refresh()
}
}
@@ -32,10 +32,10 @@ import io.legado.app.databinding.DialogBookChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.main.MainActivity
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.StartActivityContract
@@ -103,7 +103,7 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
viewModel.initData(arguments, callBack?.oldBook, MainActivity.hasActiveReadBookRoute)
showTitle()
initMenu()
initRecyclerView()
@@ -547,4 +547,4 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
fun addToBookshelf(book: Book, toc: List<BookChapter>)
}
}
}
@@ -1,164 +0,0 @@
package io.legado.app.ui.book.changesource
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.recyclerview.widget.DiffUtil
import io.legado.app.R
import io.legado.app.base.adapter.DiffRecyclerAdapter
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.visible
import splitties.views.onLongClick
class ChangeChapterSourceAdapter(
context: Context,
val viewModel: ChangeChapterSourceViewModel,
val callBack: CallBack
) : DiffRecyclerAdapter<SearchBook, ItemChangeSourceBinding>(context) {
override val diffItemCallback = object : DiffUtil.ItemCallback<SearchBook>() {
override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.bookUrl == newItem.bookUrl
}
override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean {
return oldItem.originName == newItem.originName
&& oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle()
}
}
override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding {
return ItemChangeSourceBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChangeSourceBinding,
item: SearchBook,
payloads: MutableList<Any>
) {
binding.apply {
if (payloads.isEmpty()) {
tvOrigin.text = item.originName
tvAuthor.text = item.author
tvLast.text = item.getDisplayLastChapterTitle()
tvCurrentChapterWordCount.text = item.chapterWordCountText
tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime)
if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
} else {
for (i in payloads.indices) {
val bundle = payloads[i] as Bundle
bundle.keySet().forEach {
when (it) {
"name" -> tvOrigin.text = item.originName
"latest" -> tvLast.text = item.getDisplayLastChapterTitle()
"upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
}
}
val score = callBack.getBookScore(item)
if (score > 0) {
// 已置顶
binding.ivGood.setImageResource(R.drawable.ic_praise_filled)
} else {
// 未置顶
binding.ivGood.setImageResource(R.drawable.ic_praise)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) {
binding.ivGood.setOnClickListener {
val item = getItem(holder.layoutPosition) ?: return@setOnClickListener
val score = callBack.getBookScore(item)
if (score > 0) {
// 已置顶 -> 取消置顶
binding.ivGood.setImageResource(R.drawable.ic_praise)
callBack.setBookScore(item, 0)
} else {
// 未置顶 -> 设置置顶
binding.ivGood.setImageResource(R.drawable.ic_praise_filled)
callBack.setBookScore(item, 1)
}
}
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callBack.openToc(it)
}
}
holder.itemView.onLongClick {
showMenu(holder.itemView, getItem(holder.layoutPosition))
}
}
private fun showMenu(view: View, searchBook: SearchBook?) {
searchBook ?: return
val popupMenu = PopupMenu(context, view)
popupMenu.inflate(R.menu.change_source_item)
popupMenu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_top_source -> {
callBack.topSource(searchBook)
}
R.id.menu_bottom_source -> {
callBack.bottomSource(searchBook)
}
R.id.menu_edit_source -> {
callBack.editSource(searchBook)
}
R.id.menu_disable_source -> {
callBack.disableSource(searchBook)
}
R.id.menu_delete_source -> {
callBack.deleteSource(searchBook)
updateItems(0, itemCount, listOf<Int>())
}
}
true
}
popupMenu.show()
}
interface CallBack {
val oldBookUrl: String?
fun openToc(searchBook: SearchBook)
fun topSource(searchBook: SearchBook)
fun bottomSource(searchBook: SearchBook)
fun editSource(searchBook: SearchBook)
fun disableSource(searchBook: SearchBook)
fun deleteSource(searchBook: SearchBook)
fun setBookScore(searchBook: SearchBook, score: Int)
fun getBookScore(searchBook: SearchBook): Int
}
}
@@ -0,0 +1,86 @@
package io.legado.app.ui.book.changesource
import androidx.compose.runtime.Immutable
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class ChangeChapterSourceUiState(
val searchQuery: String = "",
val isSearching: Boolean = false,
val searchProgress: Pair<Int, String> = 0 to "",
val totalSourceCount: Int = 0,
val searchResults: ImmutableList<SearchBook> = persistentListOf(),
// Options
val checkAuthor: Boolean = ChangeSourceConfig.checkAuthor,
val loadInfo: Boolean = ChangeSourceConfig.loadInfo,
val loadToc: Boolean = ChangeSourceConfig.loadToc,
val loadWordCount: Boolean = ChangeSourceConfig.loadWordCount,
// TOC view
val showToc: Boolean = false,
val selectedSourceName: String = "",
val tocItems: ImmutableList<BookChapter> = persistentListOf(),
val isLoadingToc: Boolean = false,
// Scope filter
val scopeState: ScopeUiState = ScopeUiState(
isAll = true,
isSource = false,
displayNames = emptyList(),
sourceUrls = emptyList()
),
val enabledGroups: ImmutableList<String> = persistentListOf(),
val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
// Source book map (for score flow)
val bookMap: Map<String, SearchBook> = emptyMap(),
)
@Immutable
data class ScopeUiState(
val isAll: Boolean,
val isSource: Boolean,
val displayNames: List<String>,
val sourceUrls: List<String>
)
sealed interface ChangeChapterSourceIntent {
// Search
data class UpdateQuery(val query: String) : ChangeChapterSourceIntent
data object StartStopSearch : ChangeChapterSourceIntent
data object Refresh : ChangeChapterSourceIntent
// Source selection
data class SelectSource(val searchBook: SearchBook) : ChangeChapterSourceIntent
data object BackFromToc : ChangeChapterSourceIntent
// Chapter selection
data class SelectChapter(val chapter: BookChapter) : ChangeChapterSourceIntent
// Options menu
data class SetCheckAuthor(val enabled: Boolean) : ChangeChapterSourceIntent
data class SetLoadInfo(val enabled: Boolean) : ChangeChapterSourceIntent
data class SetLoadToc(val enabled: Boolean) : ChangeChapterSourceIntent
data class SetLoadWordCount(val enabled: Boolean) : ChangeChapterSourceIntent
// Source actions
data class TopSource(val searchBook: SearchBook) : ChangeChapterSourceIntent
data class BottomSource(val searchBook: SearchBook) : ChangeChapterSourceIntent
data class DisableSource(val searchBook: SearchBook) : ChangeChapterSourceIntent
data class DeleteSource(val searchBook: SearchBook) : ChangeChapterSourceIntent
// Scope filter
data object ShowFilterSheet : ChangeChapterSourceIntent
data object DismissFilterSheet : ChangeChapterSourceIntent
data object SelectAllScope : ChangeChapterSourceIntent
data class ToggleScopeGroup(val groupName: String) : ChangeChapterSourceIntent
data class ToggleScopeSource(val source: BookSourcePart) : ChangeChapterSourceIntent
data object ApplyScope : ChangeChapterSourceIntent
}
sealed interface ChangeChapterSourceEffect {
data class ReplaceContent(val content: String) : ChangeChapterSourceEffect
data class ShowToast(val message: String) : ChangeChapterSourceEffect
data object Dismiss : ChangeChapterSourceEffect
}
@@ -1,420 +0,0 @@
package io.legado.app.ui.book.changesource
//import io.legado.app.lib.theme.primaryColor
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.bundleOf
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
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.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.DialogChapterChangeSourceBinding
import io.legado.app.help.book.BookHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.applyTint
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone
import io.legado.app.utils.observeEvent
import io.legado.app.utils.setLayout
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.transaction
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_change_source),
Toolbar.OnMenuItemClickListener,
ChangeChapterSourceAdapter.CallBack,
ChangeChapterTocAdapter.Callback {
constructor(name: String, author: String, chapterIndex: Int, chapterTitle: String) : this() {
arguments = Bundle().apply {
putString("name", name)
putString("author", author)
putInt("chapterIndex", chapterIndex)
putString("chapterTitle", chapterTitle)
}
}
private val binding by viewBinding(DialogChapterChangeSourceBinding::bind)
private val groups = linkedSetOf<String>()
private val callBack: CallBack? get() = activity as? CallBack
private val viewModel: ChangeChapterSourceViewModel by viewModels()
private val editSourceResult =
registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
viewModel.startSearch()
}
private val searchBookAdapter by lazy {
ChangeChapterSourceAdapter(requireContext(), viewModel, this)
}
private val tocAdapter by lazy {
ChangeChapterTocAdapter(requireContext(), this)
}
private val contentSuccess: (content: String) -> Unit = {
binding.loadingToc.gone()
callBack?.replaceContent(it)
dismissAllowingStateLoss()
}
private var searchBook: SearchBook? = null
private val searchFinishCallback: (isEmpty: Boolean) -> Unit = {
if (it) {
val searchScope = SearchScope(ChangeSourceConfig.searchScope)
val group = searchScope.display
if (!searchScope.isAll()) {
lifecycleScope.launch {
context?.alert("搜索结果为空") {
setMessage("${group}分组搜索结果为空,是否切换到全部分组")
noButton()
yesButton {
ChangeSourceConfig.searchScope = ""
upGroupMenu()
viewModel.startSearch()
}
}
}
}
}
}
override fun onStart() {
super.onStart()
setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity)
showTitle()
initMenu()
initView()
initRecyclerView()
initSearchView()
initBottomBar()
initLiveData()
viewModel.searchFinishCallback = searchFinishCallback
activity?.onBackPressedDispatcher?.addCallback(this) {
if (binding.clToc.isVisible) {
binding.clToc.gone()
binding.recyclerView.visible()
return@addCallback
}
dismissAllowingStateLoss()
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.searchFinishCallback = null
}
private fun showTitle() {
binding.toolBar.title = viewModel.chapterTitle
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.change_source)
//binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc)
?.isChecked = AppConfig.changeSourceLoadToc
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
}
private fun initView() {
binding.ivHideToc.setOnClickListener {
binding.clToc.gone()
binding.recyclerView.visible()
}
//binding.flHideToc.elevation = requireContext().elevation
}
private fun initRecyclerView() {
binding.recyclerView.addItemDecoration(VerticalDivider(requireContext()))
binding.recyclerView.adapter = searchBookAdapter
searchBookAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (toPosition == 0) {
binding.recyclerView.scrollToPosition(0)
}
}
})
binding.recyclerViewToc.adapter = tocAdapter
}
private fun initSearchView() {
val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView
searchView.setOnCloseListener {
showTitle()
false
}
searchView.setOnSearchClickListener {
binding.toolBar.title = ""
binding.toolBar.subtitle = ""
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.screen(newText)
return false
}
})
}
private fun initBottomBar() {
binding.tvDur.text = callBack?.oldBook?.originName
binding.tvDur.setOnClickListener {
scrollToDurSource()
}
binding.ivTop.setOnClickListener {
binding.recyclerView.scrollToPosition(0)
}
binding.ivBottom.setOnClickListener {
binding.recyclerView.scrollToPosition(searchBookAdapter.itemCount - 1)
}
}
private fun initLiveData() {
viewModel.searchStateData.observe(viewLifecycleOwner) {
binding.refreshProgressBar.isVisible = it
if (it) {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_stop_black_24dp)
item.setTitle(R.string.stop)
}
} else {
startStopMenuItem?.let { item ->
item.setIcon(R.drawable.ic_refresh)
item.setTitle(R.string.refresh)
}
}
binding.toolBar.menu.applyTint(requireContext())
}
lifecycleScope.launch {
lifecycle.currentStateFlow.first { it.isAtLeast(STARTED) }
viewModel.searchDataFlow.conflate().collect {
searchBookAdapter.setItems(it)
delay(1000)
}
}
lifecycleScope.launch {
appDb.bookSourceDao.flowEnabledGroups().conflate().collect {
groups.clear()
groups.addAll(it)
upGroupMenu()
}
}
}
private val startStopMenuItem: MenuItem?
get() = binding.toolBar.menu.findItem(R.id.menu_start_stop)
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
item.isChecked = !item.isChecked
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
R.id.menu_start_stop -> viewModel.startOrStopSearch()
R.id.menu_source_manage -> startActivity<BookSourceActivity>()
else -> if (item?.groupId == R.id.source_group && !item.isChecked) {
item.isChecked = true
if (item.title.toString() == getString(R.string.all_source)) {
ChangeSourceConfig.searchScope = ""
} else {
ChangeSourceConfig.searchScope = item.title.toString()
}
lifecycleScope.launch(IO) {
viewModel.stopSearch()
if (viewModel.refresh()) {
viewModel.startSearch()
}
}
}
}
return false
}
private fun scrollToDurSource() {
searchBookAdapter.getItems().forEachIndexed { index, searchBook ->
if (searchBook.bookUrl == oldBookUrl) {
(binding.recyclerView.layoutManager as LinearLayoutManager)
.scrollToPositionWithOffset(index, 60.dpToPx())
return
}
}
}
override fun openToc(searchBook: SearchBook) {
this.searchBook = searchBook
tocAdapter.setItems(null)
binding.recyclerView.gone()
binding.clToc.visible()
binding.loadingToc.visible()
val book = searchBook.toBook()
viewModel.getToc(book, { toc: List<BookChapter>, _: BookSource ->
tocAdapter.durChapterIndex =
BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc)
binding.loadingToc.gone()
tocAdapter.setItems(toc)
binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5)
}, {
binding.clToc.gone()
AppLog.put("单章换源获取目录出错\n$it", it, true)
})
}
override val oldBookUrl: String?
get() = callBack?.oldBook?.bookUrl
override fun topSource(searchBook: SearchBook) {
viewModel.topSource(searchBook)
}
override fun bottomSource(searchBook: SearchBook) {
viewModel.bottomSource(searchBook)
}
override fun editSource(searchBook: SearchBook) {
editSourceResult.launch {
putExtra("sourceUrl", searchBook.origin)
}
}
override fun disableSource(searchBook: SearchBook) {
viewModel.disableSource(searchBook)
}
override fun deleteSource(searchBook: SearchBook) {
viewModel.del(searchBook)
if (oldBookUrl == searchBook.bookUrl) {
viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source ->
callBack?.changeTo(source, book, toc)
}
}
}
override fun setBookScore(searchBook: SearchBook, score: Int) {
viewModel.setBookScore(searchBook, score)
}
override fun getBookScore(searchBook: SearchBook): Int {
return viewModel.getBookScore(searchBook)
}
override fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) {
searchBook?.let {
binding.loadingToc.visible()
viewModel.getContent(it.toBook(), bookChapter, nextChapterUrl, contentSuccess) { msg ->
binding.loadingToc.gone()
binding.clToc.gone()
toastOnUi(msg)
}
}
}
/**
* 更新分组菜单
*/
private fun upGroupMenu() {
binding.toolBar.menu.findItem(R.id.menu_group)?.subMenu?.transaction { menu ->
val searchScope = SearchScope(ChangeSourceConfig.searchScope)
val selectedGroup = searchScope.displayNames.firstOrNull() ?: ""
menu.removeGroup(R.id.source_group)
val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source)
var hasSelectedGroup = false
groups.forEach { group ->
menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let {
if (group == selectedGroup) {
it.isChecked = true
hasSelectedGroup = true
}
}
}
menu.setGroupCheckable(R.id.source_group, true, true)
if (!hasSelectedGroup) {
allItem.isChecked = true
}
}
}
override fun observeLiveBus() {
observeEvent<String>(EventBus.SOURCE_CHANGED) {
searchBookAdapter.notifyItemRangeChanged(
0,
searchBookAdapter.itemCount,
bundleOf(Pair("upCurSource", oldBookUrl))
)
}
}
interface CallBack {
val oldBook: Book?
fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>)
fun replaceContent(content: String)
}
}
@@ -1,46 +1,389 @@
package io.legado.app.ui.book.changesource
import android.app.Application
import android.os.Bundle
import io.legado.app.data.appDb
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.webBook.WebBook
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.usecase.ChangeSourceSearchEvent
import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
import io.legado.app.domain.usecase.GetChapterContentUseCase
import io.legado.app.ui.book.search.SearchScope
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Suppress("MemberVisibilityCanBePrivate")
class ChangeChapterSourceViewModel(application: Application) :
ChangeBookSourceViewModel(application) {
class ChangeChapterSourceViewModel(
private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
private val getChapterContentUseCase: GetChapterContentUseCase,
private val searchRepository: SearchRepository,
) : ViewModel() {
var chapterIndex: Int = 0
var chapterTitle: String = ""
private val _uiState = MutableStateFlow(ChangeChapterSourceUiState())
val uiState = _uiState.asStateFlow()
override fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) {
super.initData(arguments, book, fromReadBookActivity)
arguments?.let { bundle ->
bundle.getString("chapterTitle")?.let {
chapterTitle = it
private val _effects = MutableSharedFlow<ChangeChapterSourceEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
// Internal state
private var searchJob: Job? = null
private var oldBook: Book? = null
private var chapterIndex: Int = 0
private var chapterTitle: String = ""
private var screenKey: String = ""
private val searchResults = mutableListOf<SearchBook>()
private val bookMap = mutableMapOf<String, SearchBook>()
// Scope state
private val searchScope = SearchScope(ChangeSourceConfig.searchScope)
init {
// Load initial scope state
_uiState.update {
it.copy(
scopeState = ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
)
}
// Collect enabled groups and sources
viewModelScope.launch {
searchRepository.enabledGroups.collect { groups ->
_uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
}
}
viewModelScope.launch {
searchRepository.enabledSources.collect { sources ->
_uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
}
chapterIndex = bundle.getInt("chapterIndex")
}
}
fun getContent(
book: Book,
chapter: BookChapter,
nextChapterUrl: String?,
success: (content: String) -> Unit,
error: (msg: String) -> Unit
) {
execute {
val bookSource = appDb.bookSourceDao.getBookSource(book.origin)
?: throw NoStackTraceException("书源不存在")
WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false)
}.onSuccess {
success.invoke(it)
}.onError {
error.invoke(it.localizedMessage ?: "获取正文出错")
fun initData(book: Book, chapterIndex: Int, chapterTitle: String) {
this.oldBook = book
this.chapterIndex = chapterIndex
this.chapterTitle = chapterTitle
_uiState.update {
it.copy(
showToc = false,
tocItems = persistentListOf(),
isLoadingToc = false,
selectedSourceName = "",
)
}
startSearch()
}
fun onIntent(intent: ChangeChapterSourceIntent) {
when (intent) {
is ChangeChapterSourceIntent.UpdateQuery -> {
screenKey = intent.query.trim()
_uiState.update { it.copy(searchQuery = intent.query) }
filterResults()
}
is ChangeChapterSourceIntent.StartStopSearch -> {
if (searchJob?.isActive == true) {
stopSearch()
} else {
startSearch()
}
}
is ChangeChapterSourceIntent.Refresh -> {
startSearch()
}
is ChangeChapterSourceIntent.SelectSource -> {
selectSource(intent.searchBook)
}
is ChangeChapterSourceIntent.BackFromToc -> {
_uiState.update {
it.copy(
showToc = false,
tocItems = persistentListOf(),
isLoadingToc = false
)
}
}
is ChangeChapterSourceIntent.SelectChapter -> {
selectChapter(intent.chapter)
}
// Options
is ChangeChapterSourceIntent.SetCheckAuthor -> {
ChangeSourceConfig.checkAuthor = intent.enabled
_uiState.update { it.copy(checkAuthor = intent.enabled) }
refreshResults()
}
is ChangeChapterSourceIntent.SetLoadInfo -> {
ChangeSourceConfig.loadInfo = intent.enabled
_uiState.update { it.copy(loadInfo = intent.enabled) }
}
is ChangeChapterSourceIntent.SetLoadToc -> {
ChangeSourceConfig.loadToc = intent.enabled
_uiState.update { it.copy(loadToc = intent.enabled) }
}
is ChangeChapterSourceIntent.SetLoadWordCount -> {
ChangeSourceConfig.loadWordCount = intent.enabled
_uiState.update { it.copy(loadWordCount = intent.enabled) }
if (intent.enabled) {
startSearch()
} else {
refreshResults()
}
}
// Source actions
is ChangeChapterSourceIntent.TopSource -> {
changeSourceSearchUseCase.topSource(intent.searchBook)
refreshResults()
}
is ChangeChapterSourceIntent.BottomSource -> {
changeSourceSearchUseCase.bottomSource(intent.searchBook)
refreshResults()
}
is ChangeChapterSourceIntent.DisableSource -> {
changeSourceSearchUseCase.disableSource(intent.searchBook)
searchResults.remove(intent.searchBook)
filterResults()
}
is ChangeChapterSourceIntent.DeleteSource -> {
changeSourceSearchUseCase.deleteSource(intent.searchBook)
searchResults.remove(intent.searchBook)
filterResults()
}
// Scope
is ChangeChapterSourceIntent.ShowFilterSheet -> {
// Handled by UI
}
is ChangeChapterSourceIntent.DismissFilterSheet -> {
// Handled by UI
}
is ChangeChapterSourceIntent.SelectAllScope -> {
searchScope.update("")
saveScope()
}
is ChangeChapterSourceIntent.ToggleScopeGroup -> {
if (searchScope.isSource()) {
searchScope.update("")
}
val selected = searchScope.displayNames.toMutableSet()
if (selected.contains(intent.groupName)) {
selected.remove(intent.groupName)
} else {
selected.add(intent.groupName)
}
searchScope.update(selected.toList())
saveScope()
}
is ChangeChapterSourceIntent.ToggleScopeSource -> {
val selectedUrls = if (searchScope.isSource()) {
searchScope.sourceUrls.toMutableSet()
} else {
mutableSetOf()
}
if (selectedUrls.contains(intent.source.bookSourceUrl)) {
selectedUrls.remove(intent.source.bookSourceUrl)
} else {
selectedUrls.add(intent.source.bookSourceUrl)
}
if (selectedUrls.isEmpty()) {
searchScope.update("")
} else {
val selectedSources =
io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter {
selectedUrls.contains(it.bookSourceUrl)
}
searchScope.updateSources(selectedSources)
}
saveScope()
}
is ChangeChapterSourceIntent.ApplyScope -> {
startSearch()
}
}
}
}
private fun startSearch() {
val book = oldBook ?: return
stopSearch()
searchResults.clear()
bookMap.clear()
filterResults()
searchJob = viewModelScope.launch {
changeSourceSearchUseCase.search(
name = book.name,
author = book.author,
scope = SearchScope(ChangeSourceConfig.searchScope),
oldBook = book,
fromReadBookActivity = true,
).collect { event ->
when (event) {
is ChangeSourceSearchEvent.Started -> {
_uiState.update { it.copy(isSearching = true) }
}
is ChangeSourceSearchEvent.Progress -> {
_uiState.update {
it.copy(
searchProgress = event.processedSources to event.sourceName,
totalSourceCount = event.totalSources,
)
}
}
is ChangeSourceSearchEvent.Result -> {
searchResults.add(event.searchBook)
bookMap[event.searchBook.primaryStr()] = event.searchBook
filterResults()
}
is ChangeSourceSearchEvent.Finished -> {
_uiState.update { it.copy(isSearching = false) }
}
}
}
}
}
private fun stopSearch() {
searchJob?.cancel()
searchJob = null
_uiState.update { it.copy(isSearching = false) }
}
fun dispose() {
stopSearch()
}
private fun refreshResults() {
searchResults.clear()
bookMap.clear()
startSearch()
}
private fun filterResults() {
val filtered = if (screenKey.isEmpty()) {
searchResults.toList()
} else {
searchResults.filter {
it.name.contains(screenKey) || it.originName.contains(screenKey)
}
}
// Sort by score
val sorted = filtered.sortedWith(
compareByDescending<SearchBook> { ObservableSourceConfig.getBookScore(it) }
.thenByDescending { io.legado.app.help.config.SourceConfig.getSourceScore(it.origin) }
.thenBy { it.originOrder }
)
_uiState.update {
it.copy(
searchResults = sorted.toImmutableList(),
bookMap = bookMap.toMap()
)
}
}
private fun selectSource(searchBook: SearchBook) {
val book = searchBook.toBook()
_uiState.update {
it.copy(
showToc = true,
selectedSourceName = searchBook.originName,
isLoadingToc = true
)
}
viewModelScope.launch {
try {
val (toc, _) = getChapterContentUseCase.getToc(book)
_uiState.update {
it.copy(
tocItems = toc.toImmutableList(),
isLoadingToc = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
showToc = false,
isLoadingToc = false
)
}
_effects.tryEmit(ChangeChapterSourceEffect.ShowToast("获取目录失败"))
}
}
}
private fun selectChapter(chapter: BookChapter) {
val book = oldBook ?: return
val selectedSearchBook = _uiState.value.searchResults.find {
it.originName == _uiState.value.selectedSourceName
} ?: return
_uiState.update { it.copy(isLoadingToc = true) }
viewModelScope.launch {
try {
val searchBook = selectedSearchBook.toBook()
val toc = _uiState.value.tocItems
val nextChapterUrl = toc.getOrNull(chapter.index + 1)?.url
val content =
getChapterContentUseCase.getContent(searchBook, chapter, nextChapterUrl)
_uiState.update { it.copy(isLoadingToc = false) }
_effects.tryEmit(ChangeChapterSourceEffect.ReplaceContent(content))
_effects.tryEmit(ChangeChapterSourceEffect.Dismiss)
} catch (e: Exception) {
_uiState.update { it.copy(isLoadingToc = false) }
_effects.tryEmit(
ChangeChapterSourceEffect.ShowToast(
e.localizedMessage ?: "获取正文出错"
)
)
}
}
}
private fun saveScope() {
ChangeSourceConfig.searchScope = searchScope.toString()
_uiState.update {
it.copy(
scopeState = ScopeUiState(
isAll = searchScope.isAll(),
isSource = searchScope.isSource(),
displayNames = searchScope.displayNames,
sourceUrls = searchScope.sourceUrls
)
)
}
refreshResults()
}
fun bookScoreFlow(searchBook: SearchBook) = ObservableSourceConfig.bookScoreFlow(searchBook)
fun onBookScoreClick(searchBook: SearchBook) {
val currentScore = ObservableSourceConfig.getBookScore(searchBook)
ObservableSourceConfig.setBookScore(searchBook, if (currentScore > 0) 0 else 1)
}
}
@@ -1,74 +0,0 @@
package io.legado.app.ui.book.changesource
import android.content.Context
import android.view.ViewGroup
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.BookChapter
import io.legado.app.databinding.ItemChapterListBinding
import io.legado.app.lib.theme.ThemeUtils
//import io.legado.app.lib.theme.accentColor
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.gone
import io.legado.app.utils.themeColor
import io.legado.app.utils.visible
class ChangeChapterTocAdapter(context: Context, val callback: Callback) :
RecyclerAdapter<BookChapter, ItemChapterListBinding>(context) {
var durChapterIndex = 0
override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding {
return ItemChapterListBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemChapterListBinding,
item: BookChapter,
payloads: MutableList<Any>
) {
binding.run {
val isDur = durChapterIndex == item.index
ivVolume.gone()
ivLocked.gone()
if (isDur) {
tvChapterName.setTextColor(context.themeColor(androidx.appcompat.R.attr.colorPrimary))
} else {
tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
}
tvChapterName.text = item.title
if (item.isVolume) {
//卷名,如第一卷 突出显示
tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press))
} else {
//普通章节 保持不变
tvChapterItem.background =
ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground)
}
if (!item.tag.isNullOrEmpty() && !item.isVolume) {
//卷名不显示tag(更新时间规则)
tvTag.text = item.tag
tvTag.visible()
} else {
tvTag.gone()
}
ivChecked.setImageResource(R.drawable.ic_check)
ivChecked.visible(isDur)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
callback.clickChapter(it, getItem(holder.layoutPosition + 1)?.url)
}
}
}
interface Callback {
fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?)
}
}
@@ -29,6 +29,16 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
onOpenSearch = { keyword ->
startActivity(MainActivity.createSearchIntent(this, key = keyword))
},
onOpenReader = { bookUrl, inBookshelf, chapterChanged ->
startActivity(
MainActivity.createReadBookIntent(
context = this,
bookUrl = bookUrl,
inBookshelf = inBookshelf,
chapterChanged = chapterChanged,
)
)
},
onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath ->
startActivity<BookInfoActivity> {
putExtra("bookUrl", bookUrl)
@@ -10,6 +10,8 @@ import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
const val READER_RESULT_DELETED = 100
data class BookInfoUiState(
val book: Book? = null,
val chapterList: List<BookChapter> = emptyList(),
@@ -22,7 +22,6 @@ import io.legado.app.model.SourceCallBack
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.config.otherConfig.OtherConfig
@@ -48,6 +47,7 @@ fun BookInfoRouteScreen(
onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
onOpenSearch: (String) -> Unit,
onOpenReader: (bookUrl: String, inBookshelf: Boolean, chapterChanged: Boolean) -> Unit = { _, _, _ -> },
onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> },
onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> },
sharedTransitionScope: SharedTransitionScope? = null,
@@ -123,7 +123,15 @@ fun BookInfoRouteScreen(
ReadMangaActivity::class.java
}
else -> ReadBookActivity::class.java
else -> null
}
if (cls == null) {
onOpenReader(
effect.book.bookUrl,
effect.inBookshelf,
effect.chapterChanged,
)
return@collectLatest
}
readBookResult.launch(
Intent(activity, cls).apply {
@@ -576,7 +576,7 @@ fun ChangeSourceSheet(
val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace ->
loadingAction = true
val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook()
val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook()
viewModel.getToc(
book,
onSuccess = { toc, source ->
@@ -11,7 +11,6 @@ import coil.ImageLoader
import coil.request.SuccessResult
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.constant.BookType
@@ -56,12 +55,13 @@ import io.legado.app.ui.main.MainIntent
import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.UrlUtil
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.postEvent
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.toastOnUi
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
@@ -76,7 +76,6 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.collections.immutable.toImmutableList
import java.io.ByteArrayOutputStream
class BookInfoViewModel(
@@ -346,7 +345,7 @@ class BookInfoViewModel(
syncUiState()
}
io.legado.app.ui.book.read.ReadBookActivity.RESULT_DELETED -> {
READER_RESULT_DELETED -> {
emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK))
}
}
@@ -56,6 +56,7 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.receiver.NetworkChangedListener
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.info.READER_RESULT_DELETED
import io.legado.app.ui.book.manga.config.MangaAutoReadDialog
import io.legado.app.ui.book.manga.config.MangaClickActionConfigDialog
import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
@@ -73,7 +74,6 @@ import io.legado.app.ui.book.manga.recyclerview.WebtoonFrame
import io.legado.app.ui.book.read.EyeProtectionRefreshScheduler
import io.legado.app.ui.book.read.MangaMenu
import io.legado.app.ui.book.read.observeEyeProtectionEvents
import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.browser.WebViewActivity
@@ -185,7 +185,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
private val bookInfoActivity =
registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
if (it.resultCode == RESULT_OK) {
setResult(RESULT_DELETED)
setResult(READER_RESULT_DELETED)
super.finish()
} else {
ReadManga.loadOrUpContent()
@@ -498,7 +498,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
override fun onColorSelected(dialogId: Int, color: Int){
if (dialogId == MANGA_B)
{
AppConfig.mangaBackground = color
viewModel.setMangaBackground(color)
setBackground()
}
}
@@ -703,7 +703,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
getString(R.string.pre_download),
AppConfig.mangaPreDownloadNum
) {
AppConfig.mangaPreDownloadNum = it
viewModel.setMangaPreDownloadNum(it)
item.title = getString(R.string.pre_download_m, it)
setRecyclerViewPreloader(it)
}
@@ -757,20 +757,20 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
//点击滑动
override fun onClickScrollDisabledChanged(disabled: Boolean) {
AppConfig.disableClickScroll = disabled
viewModel.setDisableClickScroll(disabled)
}
override fun onScrollAniDisabledChanged(disabled: Boolean) {
AppConfig.disableMangaScrollAnimation = disabled
viewModel.setDisableMangaScrollAnimation(disabled)
}
override fun onCrossFadeDisabledChanged(disabled: Boolean) {
AppConfig.disableMangaCrossFade = disabled
viewModel.setDisableMangaCrossFade(disabled)
}
//双击缩放
override fun onMangaScaleDisabledChanged(disabled: Boolean) {
AppConfig.disableMangaScale = disabled
viewModel.setDisableMangaScale(disabled)
setDisableMangaScale(disabled)
}
@@ -784,16 +784,13 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
//墨水屏
override fun updateEpaperMode(enabled: Boolean, threshold: Int) {
AppConfig.enableMangaEInk = enabled
AppConfig.enableMangaGray = false
AppConfig.mangaEInkThreshold = threshold
viewModel.setEInkMode(enabled, threshold)
mAdapter.enableMangaEInk(enabled, threshold)
}
//灰度
override fun updateGrayMode(enabled: Boolean) {
AppConfig.enableMangaGray = enabled
AppConfig.enableMangaEInk = false
viewModel.setGrayMode(enabled)
mAdapter.enableGray(enabled)
}
@@ -811,7 +808,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
//自动翻页速度
override fun onAutoPageSpeedChanged(speed: Int) {
setAutoReadEnabled(false)
AppConfig.mangaAutoPageSpeed = speed
viewModel.setMangaAutoPageSpeed(speed)
mScrollTimer.setSpeed(speed)
setAutoReadEnabled(enableScroll)
// if (enableAutoScrollPage) {
@@ -820,19 +817,19 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
}
override fun onMangaLongClickChanged(checked: Boolean) {
AppConfig.mangaLongClick = checked
viewModel.setMangaLongClick(checked)
}
override fun onVolumeKeyPageChanged(enable: Boolean) {
AppConfig.MangaVolumeKeyPage = enable
viewModel.setMangaVolumeKeyPage(enable)
}
override fun onReverseVolumeKeyPageChanged(enable: Boolean) {
AppConfig.reverseVolumeKeyPage = enable
viewModel.setReverseVolumeKeyPage(enable)
}
override fun onHideMangaTitleChanged(hide: Boolean) {
AppConfig.hideMangaTitle = hide
viewModel.setHideMangaTitle(hide)
ReadManga.loadContent()
}
@@ -15,6 +15,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.Book.ReadConfig
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
import io.legado.app.data.repository.MangaSettingsRepository
import io.legado.app.domain.model.ReadingProgress
import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.exception.NoStackTraceException
@@ -50,7 +51,8 @@ import java.io.ByteArrayOutputStream
class ReadMangaViewModel(
application: Application,
private val getReadingProgressUseCase: GetReadingProgressUseCase
private val getReadingProgressUseCase: GetReadingProgressUseCase,
private val mangaSettingsRepository: MangaSettingsRepository
) : BaseViewModel(application) {
private var changeSourceCoroutine: Coroutine<*>? = null
@@ -378,6 +380,91 @@ class ReadMangaViewModel(
}
}
fun setMangaPreDownloadNum(value: Int) {
AppConfig.mangaPreDownloadNum = value
viewModelScope.launch { mangaSettingsRepository.setMangaPreDownloadNum(value) }
}
fun setMangaBackground(value: Int) {
AppConfig.mangaBackground = value
viewModelScope.launch { mangaSettingsRepository.setMangaBackground(value) }
}
fun setDisableClickScroll(value: Boolean) {
AppConfig.disableClickScroll = value
viewModelScope.launch { mangaSettingsRepository.setDisableClickScroll(value) }
}
fun setDisableMangaScrollAnimation(value: Boolean) {
AppConfig.disableMangaScrollAnimation = value
viewModelScope.launch { mangaSettingsRepository.setDisableMangaScrollAnimation(value) }
}
fun setDisableMangaCrossFade(value: Boolean) {
AppConfig.disableMangaCrossFade = value
viewModelScope.launch { mangaSettingsRepository.setDisableMangaCrossFade(value) }
}
fun setDisableMangaScale(value: Boolean) {
AppConfig.disableMangaScale = value
viewModelScope.launch { mangaSettingsRepository.setDisableMangaScale(value) }
}
fun setEInkMode(enabled: Boolean, threshold: Int) {
AppConfig.enableMangaEInk = enabled
AppConfig.enableMangaGray = false
AppConfig.mangaEInkThreshold = threshold
viewModelScope.launch {
mangaSettingsRepository.setEnableMangaEInk(enabled)
mangaSettingsRepository.setEnableMangaGray(false)
mangaSettingsRepository.setMangaEInkThreshold(threshold)
}
}
fun setGrayMode(enabled: Boolean) {
AppConfig.enableMangaGray = enabled
AppConfig.enableMangaEInk = false
viewModelScope.launch {
mangaSettingsRepository.setEnableMangaGray(enabled)
mangaSettingsRepository.setEnableMangaEInk(false)
}
}
fun setMangaAutoPageSpeed(value: Int) {
AppConfig.mangaAutoPageSpeed = value
viewModelScope.launch { mangaSettingsRepository.setMangaAutoPageSpeed(value) }
}
fun setMangaLongClick(value: Boolean) {
AppConfig.mangaLongClick = value
viewModelScope.launch { mangaSettingsRepository.setMangaLongClick(value) }
}
fun setMangaVolumeKeyPage(value: Boolean) {
AppConfig.MangaVolumeKeyPage = value
viewModelScope.launch { mangaSettingsRepository.setMangaVolumeKeyPage(value) }
}
fun setReverseVolumeKeyPage(value: Boolean) {
AppConfig.reverseVolumeKeyPage = value
viewModelScope.launch { mangaSettingsRepository.setReverseVolumeKeyPage(value) }
}
fun setHideMangaTitle(value: Boolean) {
AppConfig.hideMangaTitle = value
viewModelScope.launch { mangaSettingsRepository.setHideMangaTitle(value) }
}
fun setMangaColorFilter(value: String) {
AppConfig.mangaColorFilter = value
viewModelScope.launch { mangaSettingsRepository.setMangaAutoColorFilter(value) }
}
fun setMangaFooterConfig(value: String) {
AppConfig.mangaFooterConfig = value
viewModelScope.launch { mangaSettingsRepository.setMangaFooterConfig(value) }
}
override fun onCleared() {
super.onCleared()
changeSourceCoroutine?.cancel()
@@ -8,15 +8,17 @@ import android.widget.TextView
import io.legado.app.R
import io.legado.app.base.BaseOverlayDialogFragment
import io.legado.app.constant.PreferKey
import io.legado.app.data.repository.MangaSettingsRepository
import io.legado.app.databinding.DialogClickActionConfigBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.selector
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.putPrefInt
import io.legado.app.utils.viewbindingdelegate.viewBinding
import org.koin.android.ext.android.inject
class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) {
private val binding by viewBinding(DialogClickActionConfigBinding::bind)
private val mangaSettingsRepository by inject<MangaSettingsRepository>()
private val actions by lazy {
linkedMapOf(
@@ -66,68 +68,85 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
binding.tvTopLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionTL, action)
setClickAction(PreferKey.mangaClickActionTL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionTC, action)
setClickAction(PreferKey.mangaClickActionTC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionTR, action)
setClickAction(PreferKey.mangaClickActionTR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionML, action)
setClickAction(PreferKey.mangaClickActionML, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionMC, action)
setClickAction(PreferKey.mangaClickActionMC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionMR, action)
setClickAction(PreferKey.mangaClickActionMR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionBL, action)
setClickAction(PreferKey.mangaClickActionBL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionBC, action)
setClickAction(PreferKey.mangaClickActionBC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.mangaClickActionBR, action)
setClickAction(PreferKey.mangaClickActionBR, action)
(it as? TextView)?.text = actions[action]
}
}
}
private fun setClickAction(key: String, action: Int) {
when (key) {
PreferKey.mangaClickActionTL -> AppConfig.mangaClickActionTL = action
PreferKey.mangaClickActionTC -> AppConfig.mangaClickActionTC = action
PreferKey.mangaClickActionTR -> AppConfig.mangaClickActionTR = action
PreferKey.mangaClickActionML -> AppConfig.mangaClickActionML = action
PreferKey.mangaClickActionMC -> AppConfig.mangaClickActionMC = action
PreferKey.mangaClickActionMR -> AppConfig.mangaClickActionMR = action
PreferKey.mangaClickActionBL -> AppConfig.mangaClickActionBL = action
PreferKey.mangaClickActionBC -> AppConfig.mangaClickActionBC = action
PreferKey.mangaClickActionBR -> AppConfig.mangaClickActionBR = action
}
execute {
mangaSettingsRepository.setMangaClickAction(key, action)
}
}
private fun selectAction(success: (action: Int) -> Unit) {
context?.selector(
getString(R.string.select_action),
@@ -138,7 +157,20 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c
}
override fun onDestroy() {
if (!hasMenuClickArea()) {
AppConfig.detectMangaClickArea()
execute {
mangaSettingsRepository.setMangaClickAction(PreferKey.mangaClickActionMC, 0)
}
}
super.onDestroy()
AppConfig.detectMangaClickArea()
}
private fun hasMenuClickArea(): Boolean {
return AppConfig.mangaClickActionTL * AppConfig.mangaClickActionTC *
AppConfig.mangaClickActionTR * AppConfig.mangaClickActionML *
AppConfig.mangaClickActionMC * AppConfig.mangaClickActionMR *
AppConfig.mangaClickActionBL * AppConfig.mangaClickActionBC *
AppConfig.mangaClickActionBR == 0
}
}
@@ -7,6 +7,7 @@ import android.view.WindowManager
import androidx.core.view.isVisible
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.data.repository.MangaSettingsRepository
import io.legado.app.databinding.DialogMangaColorFilterBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.GSON
@@ -14,9 +15,11 @@ import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.invisible
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import org.koin.android.ext.android.inject
class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_manga_color_filter) {
private val binding by viewBinding(DialogMangaColorFilterBinding::bind)
private val mangaSettingsRepository by inject<MangaSettingsRepository>()
private val mConfig =
GSON.fromJsonObject<MangaColorFilterConfig>(AppConfig.mangaColorFilter).getOrNull()
?: MangaColorFilterConfig()
@@ -114,8 +117,13 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaColorFilter = mConfig.toJson()
val colorFilter = mConfig.toJson()
AppConfig.mangaColorFilter = colorFilter
AppConfig.mangaEInkThreshold = mMangaEInkThreshold
execute {
mangaSettingsRepository.setMangaAutoColorFilter(colorFilter)
mangaSettingsRepository.setMangaEInkThreshold(mMangaEInkThreshold)
}
}
interface Callback {
@@ -124,4 +132,4 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man
fun updateGrayMode(enabled: Boolean)
}
}
}
@@ -11,6 +11,7 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.data.repository.MangaSettingsRepository
import io.legado.app.databinding.DialogMangaFooterSettingBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.manga.entities.MangaFooterConfig
@@ -20,6 +21,7 @@ import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.postEvent
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import org.koin.android.ext.android.inject
class MangaFooterSettingDialog :
BaseBottomSheetDialogFragment(R.layout.dialog_manga_footer_setting) {
@@ -37,6 +39,7 @@ class MangaFooterSettingDialog :
var callback: Callback? = null
private val binding by viewBinding(DialogMangaFooterSettingBinding::bind)
private val mangaSettingsRepository by inject<MangaSettingsRepository>()
override fun onStart() {
super.onStart()
@@ -255,7 +258,11 @@ class MangaFooterSettingDialog :
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
AppConfig.mangaFooterConfig = GSON.toJson(config)
val footerConfig = GSON.toJson(config)
AppConfig.mangaFooterConfig = footerConfig
execute {
mangaSettingsRepository.setMangaFooterConfig(footerConfig)
}
}
private fun updateChapterText() {
@@ -311,4 +318,4 @@ class MangaFooterSettingDialog :
fun onMangaLongClickChanged(checked: Boolean)
}
}
}
@@ -1,432 +0,0 @@
package io.legado.app.ui.book.read
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnAttach
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.google.android.material.datepicker.MaterialDatePicker
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppConst.charsets
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.ActivityBookReadBinding
import io.legado.app.databinding.DialogDownloadChoiceBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogSimulatedReadingBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.CacheBook
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.read.config.BgTextConfigDialog
import io.legado.app.ui.book.read.config.ClickActionConfigDialog
import io.legado.app.ui.book.read.config.FontConfigDialog
import io.legado.app.ui.book.read.config.FontSelectDialog
import io.legado.app.ui.book.read.config.InfoConfigDialog
import io.legado.app.ui.book.read.config.PaddingConfigDialog
import io.legado.app.ui.book.read.config.PageKeyDialog
import io.legado.app.ui.book.read.config.ShadowSetDialog
import io.legado.app.ui.book.read.config.UnderlineConfigDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.ColorUtils
import io.legado.app.utils.FileDoc
import io.legado.app.utils.find
import io.legado.app.utils.getPrefString
import io.legado.app.utils.gone
import io.legado.app.utils.isTv
import io.legado.app.utils.setLightStatusBar
import io.legado.app.utils.setNavigationBarColorAuto
import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
import kotlinx.coroutines.launch
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.themeColor
import io.legado.app.utils.viewbindingdelegate.viewBinding
import org.koin.androidx.viewmodel.ext.android.viewModel
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* 阅读界面
*/
abstract class BaseReadBookActivity :
VMBaseActivity<ActivityBookReadBinding, ReadBookViewModel>(imageBg = false) {
override val binding by viewBinding(ActivityBookReadBinding::inflate)
override val viewModel by viewModel<ReadBookViewModel>()
protected val menuLayoutIsVisible
get() = bottomDialog > 0 || binding.readMenu.isVisible || binding.searchMenu.bottomMenuVisible
var bottomDialog = 0
set(value) {
if (field != value) {
field = value
onBottomDialogChange()
}
}
private val selectBookFolderResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
ReadBook.book?.let { book ->
FileDoc.fromUri(uri, true).find(book.originName)?.let { doc ->
book.bookUrl = doc.uri.toString()
book.save()
viewModel.loadChapterList(book)
} ?: ReadBook.upMsg("找不到文件")
}
} ?: ReadBook.upMsg("没有权限访问")
}
override fun onCreate(savedInstanceState: Bundle?) {
ReadBook.msg = null
setOrientation()
upLayoutInDisplayCutoutMode()
super.onCreate(savedInstanceState)
binding.navigationBar.doOnAttach {
binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updateLayoutParams {
height = insets.bottom
}
windowInsets
}
}
viewModel.permissionDenialLiveData.observe(this) {
selectBookFolderResult.launch {
mode = HandleFileContract.DIR_SYS
title = "选择书籍所在文件夹"
}
}
if (!LocalConfig.readHelpVersionIsLast) {
if (isTv) {
showCustomPageKeyConfig()
} else {
showClickRegionalConfig()
}
}
}
private fun onBottomDialogChange() {
when (bottomDialog) {
0 -> onMenuHide()
1 -> onMenuShow()
}
}
open fun onMenuShow() {
}
open fun onMenuHide() {
}
fun showInfoConfig() {
showDialogFragment<InfoConfigDialog>()
}
fun showFont() {
showDialogFragment<FontConfigDialog>()
}
fun showPaddingConfig() {
showDialogFragment<PaddingConfigDialog>()
}
fun showShadowSet() {
showDialogFragment<ShadowSetDialog>()
}
fun showFontSelect() {
showDialogFragment<FontSelectDialog>()
}
fun showUnderlineConfig() {
showDialogFragment<UnderlineConfigDialog>()
}
fun showBgTextConfig() {
showDialogFragment<BgTextConfigDialog>()
}
fun showClickRegionalConfig() {
showDialogFragment<ClickActionConfigDialog>()
}
private fun showCustomPageKeyConfig() {
PageKeyDialog(this).show()
}
/**
* 屏幕方向
*/
@SuppressLint("SourceLockedOrientationActivity")
fun setOrientation() {
when (AppConfig.screenOrientation) {
"0" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
"1" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
"2" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
"3" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
"4" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
}
}
/**
* 更新状态栏,导航栏
*/
fun upSystemUiVisibility(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.run {
if (toolBarHide && ReadBookConfig.hideNavigationBar) {
hide(WindowInsets.Type.navigationBars())
} else {
show(WindowInsets.Type.navigationBars())
}
if (toolBarHide && ReadBookConfig.hideStatusBar) {
hide(WindowInsets.Type.statusBars())
} else {
show(WindowInsets.Type.statusBars())
}
}
}
upSystemUiVisibilityO(isInMultiWindow, toolBarHide)
if (toolBarHide) {
setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark())
} else {
val statusBarColor =
if (AppConfig.readBarStyleFollowPage
&& ReadBookConfig.durConfig.curBgType() == 0
) {
ReadBookConfig.bgMeanColor
} else {
ReadBookConfig.bgMeanColor
}
setLightStatusBar(ColorUtils.isColorLight(statusBarColor))
}
}
@Suppress("DEPRECATION")
private fun upSystemUiVisibilityO(
isInMultiWindow: Boolean,
toolBarHide: Boolean = true
) {
var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_IMMERSIVE
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
if (!isInMultiWindow) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
if (ReadBookConfig.hideNavigationBar) {
flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
}
}
if (ReadBookConfig.hideStatusBar && toolBarHide) {
flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN
}
window.decorView.systemUiVisibility = flag
}
fun upNavigationBarColor() {
upNavigationBar()
val navColor = when {
binding.readMenu.isVisible -> themeColor(com.google.android.material.R.attr.colorSurfaceContainer)
binding.searchMenu.bottomMenuVisible -> themeColor(com.google.android.material.R.attr.colorSurface)
bottomDialog > 0 -> themeColor(com.google.android.material.R.attr.colorSurface)
else -> ReadBookConfig.bgMeanColor
}
window.setNavigationBarColorAuto(navColor)
binding.navigationBar.setBackgroundColor(navColor)
}
@SuppressLint("RtlHardcoded")
private fun upNavigationBar() {
binding.navigationBar.gone(!menuLayoutIsVisible)
}
/**
* 保持亮屏
*/
fun keepScreenOn(on: Boolean) {
val isScreenOn =
(window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0
if (on == isScreenOn) return
if (on) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* 适配刘海
*/
private fun upLayoutInDisplayCutoutMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = if (ReadBookConfig.readBodyToLh) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
}
}
}
}
@SuppressLint("InflateParams", "SetTextI18n")
fun showDownloadDialog() {
ReadBook.book?.let { book ->
alert(titleResource = R.string.offline_cache) {
val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply {
editStart.setText((book.durChapterIndex + 1).toString())
editEnd.setText(book.totalChapterNum.toString())
}
customView { alertBinding.root }
okButton {
alertBinding.run {
val start = editStart.text!!.toString().let {
if (it.isEmpty()) 0 else it.toInt()
}
val end = editEnd.text!!.toString().let {
if (it.isEmpty()) book.totalChapterNum else it.toInt()
}
lifecycleScope.launch {
CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1)
}
}
}
cancelButton()
}
}
}
fun showSimulatedReading() {
val book = ReadBook.book ?: return
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val alertBinding = DialogSimulatedReadingBinding.inflate(layoutInflater).apply {
srEnabled.isChecked = book.getReadSimulating()
editStart.setText(book.getStartChapter().toString())
editNum.setText(book.getDailyChapters().toString())
// 安全地设置初始日期
val safeDate = book.getStartDate() ?: LocalDate.now()
startDate.setText(safeDate.format(dateFormatter))
// 让 EditText 不可直接编辑,只能通过选择器
startDate.isFocusable = false
startDate.isCursorVisible = false
startDate.setOnClickListener {
val currentDate = try {
LocalDate.parse(startDate.text.toString(), dateFormatter)
} catch (e: Exception) {
LocalDate.now()
}
val initialSelection = currentDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val picker = MaterialDatePicker.Builder.datePicker()
.setTitleText("选择开始日期")
.setSelection(initialSelection)
.build()
picker.addOnPositiveButtonClickListener { selection ->
val date = Instant.ofEpochMilli(selection)
.atZone(ZoneId.systemDefault())
.toLocalDate()
startDate.setText(date.format(dateFormatter))
}
picker.show((root.context as AppCompatActivity).supportFragmentManager, "md3_date_picker")
}
}
alert(titleResource = R.string.simulated_reading) {
customView { alertBinding.root }
okButton {
alertBinding.run {
val start = editStart.text.toString().toIntOrNull() ?: 0
val num = editNum.text.toString().toIntOrNull() ?: book.totalChapterNum
val enabled = srEnabled.isChecked
val date = try {
LocalDate.parse(startDate.text.toString(), dateFormatter)
} catch (e: Exception) {
LocalDate.now()
}
book.setStartDate(date)
book.setDailyChapters(num)
book.setStartChapter(start)
book.setReadSimulating(enabled)
book.save()
ReadBook.clearTextChapter()
viewModel.initData(intent)
}
}
cancelButton()
}
}
fun showCharsetConfig() {
alert(R.string.set_charset) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "charset"
editView.setFilterValues(charsets)
editView.setText(ReadBook.book?.charset)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
ReadBook.setCharset(it)
}
}
cancelButton()
}
}
fun showPageAnimConfig(success: () -> Unit) {
val items = arrayListOf<String>()
items.add(getString(R.string.btn_default_s))
items.add(getString(R.string.page_anim_cover))
items.add(getString(R.string.page_anim_slide))
items.add(getString(R.string.page_anim_simulation))
items.add(getString(R.string.page_anim_scroll))
items.add(getString(R.string.page_anim_none))
selector(R.string.page_anim, items) { _, i ->
ReadBook.book?.setPageAnim(i - 1)
success()
}
}
fun isPrevKey(keyCode: Int): Boolean {
if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
return false
}
val prevKeysStr = getPrefString(PreferKey.prevKeys)
return prevKeysStr?.split(",")?.contains(keyCode.toString()) ?: false
}
fun isNextKey(keyCode: Int): Boolean {
if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
return false
}
val nextKeysStr = getPrefString(PreferKey.nextKeys)
return nextKeysStr?.split(",")?.contains(keyCode.toString()) ?: false
}
}
@@ -1,176 +0,0 @@
package io.legado.app.ui.book.read
import android.app.Application
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookChapter
import io.legado.app.databinding.DialogContentEditBinding
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isLocalTxt
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.model.webBook.WebBook
import io.legado.app.utils.gone
import io.legado.app.utils.sendToClip
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 内容编辑
*/
class ContentEditDialog : BaseBottomSheetDialogFragment(R.layout.dialog_content_edit) {
val binding by viewBinding(DialogContentEditBinding::bind)
val viewModel by viewModels<ContentEditViewModel>()
private val targetOffset: Int
get() = arguments?.getInt("start_position", -1).takeIf { it != -1 }
?: ReadBook.durChapterPos
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.toolBar.title = ReadBook.curTextChapter?.title
val book = ReadBook.book
if (book?.isLocalTxt == true) {
binding.cbSaveToSource.visible()
}
initMenu()
binding.toolBar.setOnClickListener {
lifecycleScope.launch {
val book1 = ReadBook.book ?: return@launch
val chapter = withContext(IO) {
appDb.bookChapterDao.getChapter(book1.bookUrl, ReadBook.durChapterIndex)
} ?: return@launch
editTitle(chapter)
}
}
viewModel.loadStateLiveData.observe(viewLifecycleOwner) {
if (it) {
binding.rlLoading.visible()
} else {
binding.rlLoading.gone()
}
}
viewModel.initContent {
binding.contentView.setText(it)
binding.contentView.post {
val layout = binding.contentView.layout ?: return@post
val targetY = binding.contentView.top +
layout.getLineTop(
layout.getLineForOffset(targetOffset)
)
binding.scrollView.smoothScrollTo(0, targetY)
//highlightSelectedTextTwice()
}
}
}
private fun initMenu() {
binding.toolBar.inflateMenu(R.menu.content_edit)
//binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_save -> {
save()
dismiss()
}
R.id.menu_reset -> viewModel.initContent(true) { content ->
binding.contentView.setText(content)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
R.id.menu_copy_all -> requireContext()
.sendToClip("${binding.toolBar.title}\n${binding.contentView.text}")
}
return@setOnMenuItemClickListener true
}
}
private fun editTitle(chapter: BookChapter) {
alert {
setTitle(R.string.edit)
val alertBinding = DialogEditTextBinding.inflate(layoutInflater)
alertBinding.editView.setText(chapter.title)
setCustomView(alertBinding.root)
okButton {
chapter.title = alertBinding.editView.text.toString()
lifecycleScope.launch {
withContext(IO) {
appDb.bookChapterDao.update(chapter)
}
binding.toolBar.title = chapter.getDisplayTitle()
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
save()
}
private fun save() {
val content = binding.contentView.text?.toString() ?: return
val saveToSource = binding.cbSaveToSource.isChecked
Coroutine.async {
val book = ReadBook.book ?: return@async
val chapter = appDb.bookChapterDao
.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@async
BookHelp.saveText(book, chapter, content, saveToSource)
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}
}
class ContentEditViewModel(application: Application) : BaseViewModel(application) {
val loadStateLiveData = MutableLiveData<Boolean>()
var content: String? = null
fun initContent(reset: Boolean = false, success: (String) -> Unit) {
execute {
val book = ReadBook.book ?: return@execute null
val chapter = appDb.bookChapterDao
.getChapter(book.bookUrl, ReadBook.durChapterIndex)
?: return@execute null
if (reset) {
content = null
BookHelp.delContent(book, chapter)
if (!book.isLocal) ReadBook.bookSource?.let { bookSource ->
WebBook.getContentAwait(bookSource, book, chapter)
}
}
return@execute content ?: let {
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val content = BookHelp.getContent(book, chapter) ?: return@let null
contentProcessor.getContent(book, chapter, content, includeTitle = false)
.toString()
}
}.onStart {
loadStateLiveData.postValue(true)
}.onSuccess {
content = it
success.invoke(it ?: "")
}.onFinally {
loadStateLiveData.postValue(false)
}
}
}
}
@@ -1,116 +0,0 @@
package io.legado.app.ui.book.read
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.data.entities.ReplaceRule
import io.legado.app.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.Item1lineTextBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.model.ReadBook
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.utils.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 起效的替换规则
*/
class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view) {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel by activityViewModels<ReadBookViewModel>()
private val adapter by lazy { ReplaceAdapter(requireContext()) }
private val chineseConvert by lazy { ReplaceRule(0, "繁简转换") }
private var isEdit = false
private val editActivity =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == AppCompatActivity.RESULT_OK) {
isEdit = true
}
}
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.effective_replaces)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
}
val effectiveReplaceRules = ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList()
if (AppConfig.chineseConverterType > 0) {
adapter.setItems(effectiveReplaceRules + chineseConvert)
} else {
adapter.setItems(effectiveReplaceRules)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (isEdit) {
viewModel.replaceRuleChanged()
}
}
private fun showChineseConvertAlert() {
alert(titleResource = R.string.chinese_converter) {
items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i ->
if (AppConfig.chineseConverterType != i) {
AppConfig.chineseConverterType = i
isEdit = true
}
}
}
}
private inner class ReplaceAdapter(context: Context) :
RecyclerAdapter<ReplaceRule, Item1lineTextBinding>(context) {
override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding {
return Item1lineTextBinding.inflate(inflater, parent, false)
}
override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) {
binding.root.setOnClickListener {
getItem(holder.layoutPosition)?.let { item ->
if (item == chineseConvert) {
showChineseConvertAlert()
return@let
}
val editRoute = ReplaceEditRoute(id = item.id, pattern = item.pattern)
ReplaceRuleActivity.startIntent(requireContext(), editRoute)
}
}
}
override fun convert(
holder: ItemViewHolder,
binding: Item1lineTextBinding,
item: ReplaceRule,
payloads: MutableList<Any>
) {
binding.textView.text = item.name
}
}
}
@@ -20,13 +20,11 @@ import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.model.ReadManga
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.utils.ConstraintModify
import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.loadAnimation
import io.legado.app.utils.modifyBegin
import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity
import io.legado.app.utils.visible
@@ -125,27 +123,12 @@ class MangaMenu @JvmOverloads constructor(
} else {
titleBarAddition.gone()
}
upBrightnessVwPos()
/**
* 确保视图不被导航栏遮挡
*/
bottomView.applyNavigationBarPadding()
}
private fun upBrightnessVwPos() {
if (AppConfig.brightnessVwPos) {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
.rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
} else {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
.leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
}
}
private fun initAnimation() {
menuTopIn.setAnimationListener(menuInListener)
menuTopOut.setAnimationListener(menuOutListener)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
package io.legado.app.ui.book.read
object ReadBookColorPickerIds {
const val SHADOW_COLOR = 123
const val TEXT_COLOR = 121
const val TEXT_ACCENT_COLOR = 125
const val BG_COLOR = 122
const val HIGHLIGHT_RULE_COLOR = 7900
const val TITLE_COLOR = 7896
const val TIP_HEADER_COLOR = 7897
const val TIP_DIVIDER_COLOR = 7898
const val TIP_FOOTER_COLOR = 7899
const val MENU_BG_COLOR = 114
const val MENU_ACCENT_COLOR = 514
const val UNDERLINE_COLOR = 810
var pendingHighlightRulePosition = -1
}
@@ -0,0 +1,298 @@
package io.legado.app.ui.book.read
import android.graphics.drawable.ColorDrawable
import androidx.compose.material3.ColorScheme
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.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.core.graphics.toColorInt
import io.legado.app.data.repository.ReadPreferences
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadStyleResolver
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ProvideThemeOverride
import io.legado.app.ui.theme.ThemeOverrideState
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.theme.buildThemeOverrideState
import io.legado.app.ui.theme.extractSeedColor
import io.legado.app.ui.theme.toSafeBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun ReadBookColorTheme(
styleConfig: ReadBookStyleConfig,
preferences: ReadPreferences,
content: @Composable () -> Unit,
) {
ProvideThemeOverride(
theme = rememberReadBookColorTheme(
styleConfig = styleConfig,
preferences = preferences,
),
content = content
)
}
@Composable
private fun rememberReadBookColorTheme(
styleConfig: ReadBookStyleConfig,
preferences: ReadPreferences,
): ThemeOverrideState? {
val isAppDark = LegadoTheme.isDark
return when (preferences.readBarStyle) {
1 -> rememberReadBackgroundTheme(styleConfig, isAppDark)
2 -> rememberCustomReadMenuTheme(
styleConfig = styleConfig,
preferences = preferences,
isAppDark = isAppDark,
)
else -> null
}
}
@Composable
private fun rememberReadBackgroundTheme(
styleConfig: ReadBookStyleConfig,
isAppDark: Boolean,
): ThemeOverrideState? {
val background = remember(styleConfig, isAppDark) {
runCatching { ReadStyleResolver.currentBackground(ReadBookConfig.durConfig) }.getOrNull()
} ?: return null
var seedColor by remember(background) { mutableStateOf<Color?>(null) }
LaunchedEffect(background, styleConfig, isAppDark) {
seedColor = when (background.type) {
0 -> background.value.toColorOrNull()
else -> extractCurrentReadBackgroundSeed()
?: ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color)
}
}
val sourceColor = seedColor ?: return null
val surfaceColor = when (background.type) {
0 -> background.value.toColorOrNull()
else -> ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color)
}
return rememberReadThemeOverride(
seedColor = sourceColor,
backgroundColor = surfaceColor,
containerColor = null,
fallbackDark = isAppDark,
)
}
@Composable
private fun rememberCustomReadMenuTheme(
styleConfig: ReadBookStyleConfig,
preferences: ReadPreferences,
isAppDark: Boolean,
): ThemeOverrideState {
val menuBackgroundColor = remember(
styleConfig,
preferences.readMenuBgColor,
preferences.readMenuBgColorNight,
isAppDark,
) {
Color(preferences.readMenuBackgroundColor(isAppDark))
}
val accentColor = remember(
styleConfig,
preferences.readMenuAccentColor,
preferences.readMenuAccentColorNight,
isAppDark,
) {
Color(preferences.readMenuAccentColor(isAppDark))
}
val menuContainerColor = remember(
styleConfig,
preferences.readMenuContainerColor,
preferences.readMenuContainerColorNight,
preferences.readMenuBgColor,
preferences.readMenuBgColorNight,
isAppDark,
) {
Color(preferences.readMenuContainerColor(isAppDark))
}
val useSeedOnly = preferences.readMenuColorMode == 0
if (!useSeedOnly) {
return rememberCustomReadMenuThemeOverride(
accentColor = accentColor,
menuBackgroundColor = menuBackgroundColor,
menuContainerColor = menuContainerColor,
isDark = isAppDark,
)
}
return rememberReadThemeOverride(
seedColor = accentColor,
backgroundColor = null,
containerColor = null,
fallbackDark = isAppDark,
deriveDarkFromColor = false,
) ?: buildReadThemeOverride(
seedColor = accentColor,
backgroundColor = null,
containerColor = null,
isDark = isAppDark,
)
}
@Composable
private fun rememberCustomReadMenuThemeOverride(
accentColor: Color,
menuBackgroundColor: Color,
menuContainerColor: Color,
isDark: Boolean,
): ThemeOverrideState {
return remember(accentColor, menuBackgroundColor, menuContainerColor, isDark) {
buildReadThemeOverride(
seedColor = accentColor,
backgroundColor = null,
containerColor = null,
isDark = isDark,
).let { base ->
base.copy(
colorScheme = base.colorScheme.withCustomReadMenuColors(
accentColor = accentColor,
menuBackgroundColor = menuBackgroundColor,
menuContainerColor = menuContainerColor,
)
)
}
}
}
@Composable
private fun rememberReadThemeOverride(
seedColor: Color,
backgroundColor: Color?,
containerColor: Color?,
fallbackDark: Boolean = LegadoTheme.isDark,
deriveDarkFromColor: Boolean = true,
): ThemeOverrideState? {
val isDark = remember(backgroundColor, containerColor, fallbackDark, deriveDarkFromColor) {
if (deriveDarkFromColor) {
(containerColor ?: backgroundColor)?.let { it.luminance() < 0.5f } ?: fallbackDark
} else {
fallbackDark
}
}
return remember(seedColor, backgroundColor, containerColor, isDark) {
buildReadThemeOverride(
seedColor = seedColor,
backgroundColor = backgroundColor,
containerColor = containerColor,
isDark = isDark,
)
}
}
private fun buildReadThemeOverride(
seedColor: Color,
backgroundColor: Color?,
containerColor: Color?,
isDark: Boolean,
): ThemeOverrideState {
val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(ThemeConfig.materialVersion)
val base = buildThemeOverrideState(
seedColor = seedColor,
isDark = isDark,
paletteStyle = ThemeResolver.resolvePaletteStyle(ThemeConfig.paletteStyle),
colorSpec = colorSpec,
usePureBlack = false,
)
return base.copy(
colorScheme = base.colorScheme.withReadSurfaceColors(
backgroundColor = backgroundColor,
containerColor = containerColor
)
)
}
private fun ColorScheme.withReadSurfaceColors(
backgroundColor: Color?,
containerColor: Color?,
): ColorScheme {
val resolvedBackground = backgroundColor ?: background
val resolvedContainer = containerColor ?: surfaceContainer
return copy(
background = resolvedBackground,
surface = resolvedBackground,
surfaceDim = resolvedBackground,
surfaceBright = resolvedBackground,
surfaceContainerLowest = resolvedBackground,
surfaceContainer = resolvedContainer,
)
}
private fun ColorScheme.withCustomReadMenuColors(
accentColor: Color,
menuBackgroundColor: Color,
menuContainerColor: Color,
): ColorScheme {
return copy(
primary = accentColor,
onPrimary = accentColor.contrastContentColor(),
surfaceTint = accentColor,
surfaceContainerHigh = menuBackgroundColor,
surfaceContainerLow = menuContainerColor,
)
}
private fun Color.contrastContentColor(): Color {
return if (luminance() > 0.5f) Color.Black else Color.White
}
private fun ReadPreferences.readMenuBackgroundColor(isDark: Boolean): Int {
return if (isDark) {
readMenuBgColorNight.takeIf { it != 0 } ?: ReadBookConfig.durConfig.menuBgColor(isNight = true)
} else {
readMenuBgColor.takeIf { it != 0 } ?: ReadBookConfig.durConfig.menuBgColor(isNight = false)
}
}
private fun ReadPreferences.readMenuAccentColor(isDark: Boolean): Int {
return if (isDark) {
readMenuAccentColorNight.takeIf { it != 0 }
?: ReadBookConfig.durConfig.menuAccentColor(isNight = true)
} else {
readMenuAccentColor.takeIf { it != 0 }
?: ReadBookConfig.durConfig.menuAccentColor(isNight = false)
}
}
private fun ReadPreferences.readMenuContainerColor(isDark: Boolean): Int {
return if (isDark) {
readMenuContainerColorNight.takeIf { it != 0 } ?: readMenuBackgroundColor(isDark = true)
} else {
readMenuContainerColor.takeIf { it != 0 } ?: readMenuBackgroundColor(isDark = false)
}
}
private suspend fun extractCurrentReadBackgroundSeed(): Color? {
return withContext(Dispatchers.Default) {
runCatching {
val drawable = ReadStyleResolver.currentBackgroundDrawable(
config = ReadBookConfig.durConfig,
width = 128,
height = 128
)
if (drawable is ColorDrawable) {
Color(drawable.color)
} else {
Color(drawable.toSafeBitmap(128).extractSeedColor())
}
}.getOrNull()
}
}
private fun String.toColorOrNull(): Color? {
return runCatching { Color(toColorInt()) }.getOrNull()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,473 @@
package io.legado.app.ui.book.read
import android.content.Intent
import android.view.KeyEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.R
import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.help.IntentData
import io.legado.app.help.IntentHelp
import io.legado.app.model.ReadBook
import io.legado.app.utils.toastOnUi
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.read.page.ContentTextView
import io.legado.app.ui.book.read.page.ReadView
import io.legado.app.ui.book.read.page.entities.PageDirection
import io.legado.app.ui.book.searchContent.SearchContentActivity
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.replace.ReplaceEditRoute
import io.legado.app.ui.replace.ReplaceRuleActivity
import io.legado.app.utils.StartActivityContract
import io.legado.app.utils.takePersistablePermissionSafely
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
data class ReadBookViewRefs(
val root: FrameLayout,
val readView: ReadView,
val textMenuPosition: View,
val cursorLeft: ImageView,
val cursorRight: ImageView,
val navigationBar: View,
)
interface ReadBookRouteHost :
View.OnTouchListener,
ReadView.CallBack,
ContentTextView.CallBack {
val isInMultiWindowModeCompat: Boolean
fun closeReadBook()
fun upSystemUiVisibility(
isInMultiWindow: Boolean,
toolBarHide: Boolean,
)
}
/**
* Narrow interface for hardware input delegation from Activity.
* MainActivity holds this instead of the full bridge/controller.
*/
interface ReadBookInputHandler {
fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean
fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean
fun mouseWheelPage(direction: PageDirection)
fun handleKeyPage(direction: PageDirection, longPress: Boolean = false)
fun toggleMenu()
}
/**
* Outer wrapper for ReadBookScreen handles system UI state sync
* and ActivityResult launcher registration.
*/
@Composable
fun ReadBookRouteScreen(
viewModel: ReadBookViewModel,
host: ReadBookRouteHost,
controller: ReadBookController,
onEffectsReady: () -> Unit = {},
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
val context = LocalContext.current
val menuBackdrop = rememberLayerBackdrop()
val menuHazeState = remember { HazeState() }
val useMenuHazeSource = state.menuConfig.readMenuTopBarBlurMode == ReadMenuBlurMode.Haze ||
state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.Haze ||
(
!state.menuConfig.readMenuFloatingBottomBar &&
state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass
)
// ── ActivityResult Launchers ──────────────────────────────────────
val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
result?.let { (index, chapterPos, _) ->
viewModel.onIntent(ReadBookIntent.OpenChapterResult(index, chapterPos))
}
}
val sourceEditLauncher = rememberLauncherForActivityResult(
StartActivityContract(BookSourceEditActivity::class.java)
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
viewModel.onIntent(ReadBookIntent.SourceEditResult)
}
}
val replaceLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
viewModel.onIntent(ReadBookIntent.ReplaceRuleResult)
}
}
val fontFolderPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
it.takePersistablePermissionSafely(context)
viewModel.onIntent(ReadBookIntent.FontFolderSelected(it))
}
}
val booksDirPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocumentTree()
) { uri ->
uri?.let {
it.takePersistablePermissionSafely(context)
viewModel.onIntent(ReadBookIntent.BooksDirSelected(it))
}
}
val readStyleImagePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleImageSelected(it)) }
}
var pendingReadStyleImageIsNight by remember { mutableStateOf(false) }
val readStyleImagePickerForMode = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
viewModel.onIntent(ReadBookIntent.ReadStyleImageSelectedForMode(it, pendingReadStyleImageIsNight))
}
}
val readStyleImportPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleConfigImportSelected(it)) }
}
val readStyleExportPicker = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/zip")
) { uri ->
uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleConfigExportSelected(it)) }
}
var pendingMenuCustomIconId by remember { mutableStateOf<String?>(null) }
val menuCustomIconPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
val id = pendingMenuCustomIconId
pendingMenuCustomIconId = null
if (id != null && uri != null) {
viewModel.onIntent(ReadBookIntent.SaveMenuCustomIcon(id, uri))
}
}
var pendingTitleBarCustomIconId by remember { mutableStateOf<String?>(null) }
val titleBarCustomIconPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri ->
val id = pendingTitleBarCustomIconId
pendingTitleBarCustomIconId = null
if (id != null && uri != null) {
viewModel.onIntent(ReadBookIntent.SaveTitleBarCustomIcon(id, uri))
}
}
val txtTocRuleLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.getStringExtra("tocRegex")?.let { rule ->
viewModel.onIntent(ReadBookIntent.TocRegexResult(rule))
}
}
}
val searchContentLauncher = rememberLauncherForActivityResult(
StartActivityContract(SearchContentActivity::class.java)
) { result ->
val data = result.data ?: return@rememberLauncherForActivityResult
val key = data.getLongExtra("key", System.currentTimeMillis())
val index = data.getIntExtra("index", 0)
val searchResult = IntentData.get<SearchResult>("searchResult$key")
val searchResultList = IntentData.get<List<SearchResult>>("searchResultList$key")
if (searchResult != null && searchResultList != null) {
viewModel.onIntent(ReadBookIntent.SetSearchResults(searchResultList, index, searchResult.query))
}
}
val bookInfoLauncher = rememberLauncherForActivityResult(
StartActivityContract(BookInfoActivity::class.java)
) { result ->
viewModel.onIntent(ReadBookIntent.BookInfoResult(result.resultCode == android.app.Activity.RESULT_OK))
}
// ── Effect collection: route handles launcher effects, rest goes to bridge ──
LaunchedEffect(viewModel) {
coroutineScope {
val collector = launch {
viewModel.effects.collect { effect ->
when (effect) {
// Launcher-dependent effects — handled directly by route
is ReadBookEffect.OpenChapterList -> {
tocLauncher.launch(effect.bookUrl)
}
is ReadBookEffect.OpenSourceEdit -> {
sourceEditLauncher.launch { putExtra("sourceUrl", effect.sourceUrl) }
}
is ReadBookEffect.OpenBookInfo -> {
bookInfoLauncher.launch {
putExtra("name", effect.name)
putExtra("author", effect.author)
putExtra("bookUrl", effect.bookUrl)
}
}
is ReadBookEffect.ShowLogin -> {
context.startActivity(
Intent(context, SourceLoginActivity::class.java).apply {
putExtra("type", "bookSource")
putExtra("key", effect.sourceUrl)
}
)
}
is ReadBookEffect.OpenWebView -> {
context.startActivity(
Intent(context, WebViewActivity::class.java).apply {
putExtra("title", effect.title)
putExtra("url", effect.url)
putExtra("sourceOrigin", effect.sourceOrigin)
putExtra("sourceName", effect.sourceName)
effect.sourceType?.let { putExtra("sourceType", it) }
}
)
}
is ReadBookEffect.OpenSearchActivity -> {
val currentState = viewModel.uiState.value
val lambda: (Intent.() -> Unit)? = { intent ->
intent.putExtra("bookUrl", effect.bookUrl)
intent.putExtra("searchWord", effect.word)
intent.putExtra("searchResultIndex", currentState.searchResultIndex)
currentState.searchResultList.firstOrNull()?.let {
if (it.query == currentState.searchContentQuery) {
IntentData.put("searchResultList", currentState.searchResultList)
}
}
}
searchContentLauncher.launch(lambda)
}
is ReadBookEffect.MenuSettingReplace -> {
replaceLauncher.launch(Intent(context, ReplaceRuleActivity::class.java))
}
is ReadBookEffect.TextActionReplace -> {
val scopes = arrayListOf<String>()
effect.bookName?.let { scopes.add(it) }
effect.bookSourceUrl?.let { scopes.add(it) }
val text = effect.text.lineSequence().map { it.trim() }.joinToString("\n")
val editRoute = ReplaceEditRoute(
id = -1, pattern = text,
scope = scopes.joinToString(";"),
isScopeTitle = false, isScopeContent = true,
)
replaceLauncher.launch(ReplaceRuleActivity.startIntent(context, editRoute))
}
is ReadBookEffect.OpenReplaceEditor -> {
val editRoute = ReplaceEditRoute(id = effect.id, pattern = effect.pattern)
replaceLauncher.launch(ReplaceRuleActivity.startIntent(context, editRoute))
}
is ReadBookEffect.MenuTocRegex -> {
val intent = Intent(context, TxtTocRuleActivity::class.java)
intent.putExtra("tocRegex", effect.tocRegex)
txtTocRuleLauncher.launch(intent)
}
is ReadBookEffect.OpenFontFolderPicker -> {
fontFolderPicker.launch(null)
}
is ReadBookEffect.OpenBooksDirPicker -> {
booksDirPicker.launch(null)
}
is ReadBookEffect.OpenReadStyleImagePicker -> {
readStyleImagePicker.launch(arrayOf("image/*"))
}
is ReadBookEffect.OpenReadStyleImagePickerForMode -> {
pendingReadStyleImageIsNight = effect.isNight
readStyleImagePickerForMode.launch(arrayOf("image/*"))
}
is ReadBookEffect.OpenReadStyleImport -> {
readStyleImportPicker.launch(
arrayOf("application/zip", "application/octet-stream", "*/*")
)
}
is ReadBookEffect.OpenReadStyleExport -> {
readStyleExportPicker.launch("readConfig.zip")
}
is ReadBookEffect.OpenMenuCustomIconPicker -> {
pendingMenuCustomIconId = effect.id
menuCustomIconPicker.launch("image/*")
}
is ReadBookEffect.OpenTitleBarCustomIconPicker -> {
pendingTitleBarCustomIconId = effect.id
titleBarCustomIconPicker.launch("image/*")
}
is ReadBookEffect.OpenSystemTtsSettings -> {
IntentHelp.openTTSSetting()
}
is ReadBookEffect.TtsCacheCleared -> {
context.toastOnUi(effect.message)
}
// All other effects — delegate to bridge (View/Window/Activity operations)
else -> controller.handleEffect(effect)
}
}
}
yield()
onEffectsReady()
collector.join()
}
}
// ── System UI sync ────────────────────────────────────────────────
LaunchedEffect(state.menuVisible) {
host.upSystemUiVisibility(host.isInMultiWindowModeCompat, !state.menuVisible)
}
// ── View layer + Compose UI ───────────────────────────────────────
Box(Modifier.fillMaxSize()) {
key(controller) {
ReadBookViewLayer(
modifier = Modifier
.then(if (useMenuHazeSource) Modifier.hazeSource(menuHazeState) else Modifier)
.layerBackdrop(menuBackdrop),
onRefsReady = { controller.onRefsReady(it) },
onCursorTouch = controller,
readViewCallBack = controller,
contentTextViewCallBack = controller,
)
}
ReadBookColorTheme(
styleConfig = state.styleConfig,
preferences = readPreferences,
) {
ReadBookMenuBar(
state = state,
onIntent = viewModel::onIntent,
backdrop = menuBackdrop,
hazeState = if (useMenuHazeSource) menuHazeState else null,
)
ReadBookSearchBar(state = state, onIntent = viewModel::onIntent)
ReadBookScreen(
state = state,
onIntent = viewModel::onIntent,
onBack = { controller.closeReadBook() },
)
}
}
}
@Composable
private fun ReadBookViewLayer(
modifier: Modifier = Modifier,
onRefsReady: (ReadBookViewRefs) -> Unit,
onCursorTouch: View.OnTouchListener,
readViewCallBack: ReadView.CallBack,
contentTextViewCallBack: ContentTextView.CallBack,
) {
AndroidView(
modifier = modifier.fillMaxSize(),
factory = { context ->
FrameLayout(context).apply {
val readView = ReadView(
context = context,
callBack = readViewCallBack,
contentCallBack = contentTextViewCallBack,
).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
}
val textMenuPosition = View(context).apply {
id = R.id.text_menu_position
visibility = View.INVISIBLE
layoutParams = FrameLayout.LayoutParams(0, 0)
}
val cursorLeft = ImageView(context).apply {
id = R.id.cursor_left
contentDescription = context.getString(R.string.select_start)
setImageResource(R.drawable.ic_cursor_left)
visibility = View.INVISIBLE
setOnTouchListener(onCursorTouch)
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
}
val cursorRight = ImageView(context).apply {
id = R.id.cursor_right
contentDescription = context.getString(R.string.select_end)
setImageResource(R.drawable.ic_cursor_right)
visibility = View.INVISIBLE
setOnTouchListener(onCursorTouch)
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
)
}
val navigationBar = View(context).apply {
id = R.id.navigation_bar
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
0,
android.view.Gravity.BOTTOM,
)
}
addView(readView)
addView(textMenuPosition)
addView(cursorLeft)
addView(cursorRight)
addView(navigationBar)
onRefsReady(
ReadBookViewRefs(
root = this,
readView = readView,
textMenuPosition = textMenuPosition,
cursorLeft = cursorLeft,
cursorRight = cursorRight,
navigationBar = navigationBar,
)
)
}
},
)
}
@@ -0,0 +1,414 @@
package io.legado.app.ui.book.read
import androidx.activity.compose.BackHandler
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.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.ui.book.info.ChangeSourceSheet
import io.legado.app.ui.widget.components.log.AppLogSheet
import io.legado.app.ui.book.read.sheet.BgTextConfigSheet
import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet
import io.legado.app.ui.book.read.sheet.CharsetConfigSheet
import io.legado.app.ui.book.read.sheet.ClickActionConfigSheet
import io.legado.app.ui.book.read.sheet.ContentEditSheet
import io.legado.app.ui.book.read.sheet.DictSheet
import io.legado.app.ui.book.read.sheet.DownloadSheet
import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet
import io.legado.app.ui.book.read.sheet.FontSelectSheet
import io.legado.app.ui.book.read.sheet.MoreConfigSheet
import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet
import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet
import io.legado.app.ui.book.read.sheet.PhotoSheet
import io.legado.app.ui.book.read.sheet.ReadAloudConfigSheet
import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet
import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
import io.legado.app.ui.book.read.sheet.ShadowSetSheet
import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet
import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet
import io.legado.app.ui.book.read.sheet.TitleBarIconSheet
import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.flow.collectLatest
/**
* Stateless ReadBook screen renders BackHandler + dialogs + sheets.
* ReadView is hosted in the XML layout, not here.
*/
@Composable
fun ReadBookScreen(
state: ReadBookUiState,
onIntent: (ReadBookIntent) -> Unit,
onBack: () -> Unit,
) {
BackHandler {
when {
state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch)
state.menuVisible -> onIntent(ReadBookIntent.ReadMenuBack)
state.isAutoPage -> onIntent(ReadBookIntent.StopAutoPage)
else -> onBack()
}
}
// Dialogs driven by activeDialog state
when (val dialog = state.activeDialog) {
is ReadBookDialog.ConfirmRestoreProgress -> {
AppAlertDialog(
show = true,
onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) },
title = stringResource(R.string.restore_progress),
text = stringResource(R.string.found_cloud_progress),
confirmText = stringResource(R.string.ok),
onConfirm = {
onIntent(ReadBookIntent.SureNewProgress(dialog.progress))
onIntent(ReadBookIntent.DismissDialog)
},
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.DismissDialog) },
)
}
is ReadBookDialog.SureSyncProgress -> {
AppAlertDialog(
show = true,
onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) },
title = stringResource(R.string.sync_progress),
text = stringResource(R.string.progress_exceeds_cloud),
confirmText = stringResource(R.string.ok),
onConfirm = {
onIntent(ReadBookIntent.SureSyncProgress(dialog.progress))
onIntent(ReadBookIntent.DismissDialog)
},
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.DismissDialog) },
)
}
is ReadBookDialog.ConfirmSkipToChapter -> {
AppAlertDialog(
show = true,
onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) },
title = stringResource(R.string.chapter_list),
text = stringResource(R.string.confirm_skip_to_chapter),
confirmText = stringResource(R.string.ok),
onConfirm = { onIntent(ReadBookIntent.DismissDialog) },
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.DismissDialog) },
)
}
is ReadBookDialog.ConfirmChapterPay -> {
AppAlertDialog(
show = true,
onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) },
title = stringResource(R.string.chapter_pay),
text = dialog.chapterTitle,
confirmText = stringResource(R.string.ok),
onConfirm = {
onIntent(ReadBookIntent.DismissDialog)
onIntent(ReadBookIntent.ConfirmPayAction)
},
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.DismissDialog) },
)
}
null -> {}
}
// AppModalBottomSheet-based sheets — always composed, controlled by show flag
// for proper enter/exit animations
val dismissSheet = { onIntent(ReadBookIntent.DismissSheet) }
ShadowSetSheet(
show = state.activeSheet is ReadBookSheet.ShadowSet,
onDismissRequest = dismissSheet,
onIntent = onIntent,
)
EffectiveReplacesSheet(
show = state.activeSheet is ReadBookSheet.EffectiveReplaces,
onDismissRequest = dismissSheet,
onOpenReplaceEditor = { id, pattern ->
onIntent(ReadBookIntent.OpenReplaceEditor(id, pattern))
},
onReplaceRuleChanged = { onIntent(ReadBookIntent.ReplaceRuleChanged) },
)
UnderlineConfigSheet(
show = state.activeSheet is ReadBookSheet.UnderlineConfig,
onDismissRequest = dismissSheet,
onIntent = onIntent,
)
FontSelectSheet(
show = state.activeSheet is ReadBookSheet.FontSelect,
onDismissRequest = dismissSheet,
onSelectFont = { onIntent(ReadBookIntent.SelectFont(it)) },
onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectSystemTypeface(it)) },
onOpenFolderPicker = { onIntent(ReadBookIntent.OpenFontFolderPicker) },
)
ToolButtonConfigSheet(
show = state.activeSheet is ReadBookSheet.ToolButtonConfig,
items = state.menuConfig.bottomBarButtons,
customIcons = state.menuConfig.readMenuCustomIcons,
onDismissRequest = dismissSheet,
onIntent = onIntent,
)
TitleBarIconSheet(
show = state.activeSheet is ReadBookSheet.TitleBarIconConfig,
items = state.menuConfig.titleBarButtons,
customIcons = state.menuConfig.titleBarCustomIcons,
onDismissRequest = dismissSheet,
onIntent = onIntent,
)
HighlightRuleConfigSheet(
show = state.activeSheet is ReadBookSheet.HighlightRuleConfig,
onDismissRequest = dismissSheet,
onIntent = onIntent,
)
ContentEditSheet(
show = state.activeSheet is ReadBookSheet.ContentEdit,
state = state,
onIntent = onIntent,
onDismissRequest = dismissSheet,
)
MoreConfigSheet(
show = state.activeSheet is ReadBookSheet.MoreConfig,
onDismissRequest = dismissSheet,
onIntent = onIntent,
onOpenClickRegionalConfig = {
onIntent(ReadBookIntent.DismissSheet)
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ClickActionConfig))
},
onOpenPageKeyConfig = {
onIntent(ReadBookIntent.DismissSheet)
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig))
},
)
ReadAloudConfigSheet(
show = state.activeSheet is ReadBookSheet.ReadAloudConfig,
state = state,
onIntent = onIntent,
onDismissRequest = dismissSheet,
)
SpeakEngineConfigSheet(
show = state.activeSheet is ReadBookSheet.SpeakEngineConfig,
items = state.ttsEngineItems,
selectedValue = state.selectedTtsEngine,
onSelect = { onIntent(ReadBookIntent.ApplySpeakEngine(it)) },
onDismissRequest = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
},
)
ReadAloudNumberConfigSheet(
show = state.activeSheet is ReadBookSheet.PreDownloadConfig,
title = stringResource(R.string.read_aloud_preload),
description = stringResource(R.string.read_aloud_preload_summary, state.preDownloadNum),
value = state.preDownloadNum,
defaultValue = 10,
valueRange = 0f..100f,
onValueChange = { onIntent(ReadBookIntent.ApplyPreDownloadNum(it)) },
onDismissRequest = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
},
)
ReadAloudNumberConfigSheet(
show = state.activeSheet is ReadBookSheet.AudioCacheCleanConfig,
title = stringResource(R.string.audio_cache_clean_time),
description = stringResource(
R.string.audio_cache_clean_time_summary,
state.audioCacheCleanTime
),
value = state.audioCacheCleanTime,
defaultValue = 10,
valueRange = 0f..10080f,
onValueChange = { onIntent(ReadBookIntent.ApplyAudioCacheCleanTime(it)) },
onDismissRequest = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
},
)
AppLogSheet(
show = state.activeSheet is ReadBookSheet.AppLog,
onDismissRequest = dismissSheet,
)
BgTextConfigSheet(
show = state.activeSheet is ReadBookSheet.BgTextConfig,
onDismissRequest = dismissSheet,
onIntent = onIntent,
onSelectImage = { onIntent(ReadBookIntent.OpenReadStyleImagePicker) },
onSelectImageForMode = { isNight ->
onIntent(ReadBookIntent.OpenReadStyleImagePickerForMode(isNight))
},
onImportConfig = { onIntent(ReadBookIntent.OpenReadStyleImport) },
onExportConfig = { onIntent(ReadBookIntent.OpenReadStyleExport) },
styleConfig = state.styleConfig,
)
val dictSheet = state.activeSheet as? ReadBookSheet.Dict
DictSheet(
show = dictSheet != null,
word = dictSheet?.word ?: "",
onDismissRequest = dismissSheet,
)
val photoSheet = state.activeSheet as? ReadBookSheet.Photo
PhotoSheet(
show = photoSheet != null,
src = photoSheet?.src ?: "",
sourceOrigin = photoSheet?.sourceOrigin,
onDismissRequest = dismissSheet,
)
// AlertDialog-based sheets and special cases — conditionally composed
when (state.activeSheet) {
is ReadBookSheet.ClickActionConfig -> {
ClickActionConfigSheet(
onDismissRequest = dismissSheet,
)
}
is ReadBookSheet.PageKeyConfig -> {
PageKeyConfigSheet(
onDismissRequest = dismissSheet,
)
}
is ReadBookSheet.PageAnim -> {
PageAnimConfigSheet(
onDismissRequest = dismissSheet,
onAnimChanged = { onIntent(ReadBookIntent.PageAnimChanged) },
)
}
is ReadBookSheet.Download -> {
DownloadSheet(
onDismissRequest = dismissSheet,
onDownload = { start, end ->
onIntent(ReadBookIntent.DismissSheet)
onIntent(ReadBookIntent.DownloadChapters(start, end))
},
)
}
is ReadBookSheet.Charset -> {
CharsetConfigSheet(
onDismissRequest = dismissSheet,
)
}
is ReadBookSheet.SimulatedReading -> {
SimulatedReadingSheet(
onDismissRequest = dismissSheet,
onApply = { onIntent(ReadBookIntent.ApplySimulatedReading) },
)
}
is ReadBookSheet.Bookmark -> {
// Handled by ViewModel — redirects to menu route
}
is ReadBookSheet.InfoConfig -> {
// Integrated into ReadStyleSheet's HeaderFooterPage
LaunchedEffect(state.activeSheet) {
onIntent(ReadBookIntent.DismissSheet)
}
}
is ReadBookSheet.ChangeChapterSource -> {
val sheet = state.activeSheet
val book = state.book
if (book != null) {
var showSheet by remember { mutableStateOf(true) }
LaunchedEffect(showSheet) {
if (!showSheet) {
kotlinx.coroutines.delay(300)
onIntent(ReadBookIntent.SetActiveSheet(null))
}
}
val viewModel = androidx.compose.runtime.key(
"chapter-source-${book.bookUrl}-${sheet.chapterIndex}"
) {
org.koin.androidx.compose.koinViewModel<io.legado.app.ui.book.changesource.ChangeChapterSourceViewModel>()
}
androidx.compose.runtime.DisposableEffect(viewModel) {
onDispose { viewModel.dispose() }
}
LaunchedEffect(book.bookUrl, sheet.chapterIndex) {
viewModel.initData(
book,
sheet.chapterIndex,
sheet.chapterTitle
)
}
val context = androidx.compose.ui.platform.LocalContext.current
ChangeChapterSourceSheet(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
onIntent = viewModel::onIntent,
show = showSheet,
onDismissRequest = { showSheet = false },
onAnimationFinish = { onIntent(ReadBookIntent.SetActiveSheet(null)) },
bookScoreFlow = viewModel::bookScoreFlow,
onBookScoreClick = viewModel::onBookScoreClick,
onEditSource = { sourceUrl ->
onIntent(ReadBookIntent.OpenSourceEditByUrl(sourceUrl))
},
)
// Handle ReplaceContent effect
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.ReplaceContent -> {
showSheet = false
onIntent(ReadBookIntent.SaveChapterContent(effect.content, sheet.chapterIndex))
}
is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.ShowToast -> {
context.toastOnUi(effect.message)
}
is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.Dismiss -> {
// Handled by showSheet animation — no-op
}
}
}
}
} else {
LaunchedEffect(sheet) {
onIntent(ReadBookIntent.DismissSheet)
}
}
}
is ReadBookSheet.ChangeBookSource -> {
val sheet = state.activeSheet
val book = state.book
if (book != null) {
ChangeSourceSheet(
show = true,
oldBook = book,
onDismissRequest = { onIntent(ReadBookIntent.DismissSheet) },
onReplace = { _, newBook, toc, _ ->
onIntent(ReadBookIntent.DismissSheet)
onIntent(ReadBookIntent.ChangeSource(newBook, toc))
},
onAddAsNew = { newBook, toc ->
onIntent(ReadBookIntent.DismissSheet)
onIntent(ReadBookIntent.AddSourceAsNewBook(newBook, toc))
},
)
} else {
LaunchedEffect(sheet) {
onIntent(ReadBookIntent.DismissSheet)
}
}
}
null -> {}
// Sheets using AppModalBottomSheet are composed unconditionally above
else -> {}
}
}
@@ -0,0 +1,246 @@
package io.legado.app.ui.book.read
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Compose replacement for SearchMenu search result navigation overlay.
*/
@Composable
fun ReadBookSearchBar(
state: ReadBookUiState,
onIntent: (ReadBookIntent) -> Unit,
) {
val searchVisible = state.isShowingSearchResult &&
!(state.menuVisible && state.menuState.currentRoute != ReadBookMenuRoute.Main)
val hasResults = state.searchResultList.isNotEmpty()
val currentIndex = state.searchResultIndex
val totalResults = state.searchResultList.size
val currentResult = if (hasResults && currentIndex in state.searchResultList.indices) {
state.searchResultList[currentIndex]
} else null
Box(Modifier.fillMaxSize()) {
// Left FAB - previous result
AnimatedVisibility(
visible = searchVisible && hasResults && currentIndex > 0,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp),
) {
SmallFloatingActionButton(
onClick = {
val prevIndex = currentIndex - 1
onIntent(
ReadBookIntent.NavigateToSearchResult(
state.searchResultList[prevIndex], prevIndex
)
)
},
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Previous",
modifier = Modifier.size(20.dp),
)
}
}
// Right FAB - next result
AnimatedVisibility(
visible = searchVisible && hasResults && currentIndex < totalResults - 1,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp),
) {
SmallFloatingActionButton(
onClick = {
val nextIndex = currentIndex + 1
onIntent(
ReadBookIntent.NavigateToSearchResult(
state.searchResultList[nextIndex], nextIndex
)
)
},
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Next",
modifier = Modifier.size(20.dp),
)
}
}
// Tap background to dismiss search menu
AnimatedVisibility(
visible = searchVisible && state.searchMenuVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { onIntent(ReadBookIntent.HideSearchMenu) }
)
}
// Bottom menu
AnimatedVisibility(
visible = searchVisible && state.searchMenuVisible,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter),
) {
SearchBottomMenu(
state = state,
currentResult = currentResult,
onIntent = onIntent,
)
}
}
}
@Composable
private fun SearchBottomMenu(
state: ReadBookUiState,
currentResult: io.legado.app.ui.book.searchContent.SearchResult?,
onIntent: (ReadBookIntent) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(bottom = 16.dp),
) {
// Search progress info
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Fraction: "3 / 10"
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${state.searchResultIndex + 1} / ${state.searchResultList.size}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
val percent = if (state.searchResultList.isNotEmpty()) {
((state.searchResultIndex + 1) * 100 / state.searchResultList.size)
} else 0
Text(
text = "$percent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Current chapter
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = state.chapterName,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
}
}
Spacer(Modifier.height(4.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(
onClick = { onIntent(ReadBookIntent.OpenSearch(null)) },
modifier = Modifier.weight(1f),
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("搜索内容")
}
TextButton(
onClick = { onIntent(ReadBookIntent.ShowMenu) },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("主菜单")
}
IconButton(
onClick = { onIntent(ReadBookIntent.ExitSearch) },
) {
Icon(Icons.Default.Close, contentDescription = "Exit search")
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,948 +0,0 @@
package io.legado.app.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.widget.FrameLayout
import android.widget.SeekBar
import androidx.annotation.OptIn
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.toColorInt
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.core.view.doOnAttach
import androidx.core.view.forEach
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.google.android.material.badge.BadgeDrawable
import com.google.android.material.badge.BadgeUtils
import com.google.android.material.badge.ExperimentalBadgeUtils
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonGroup
import com.google.android.material.overflow.OverflowLinearLayout
import com.google.android.material.slider.Slider
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.ViewReadMenuBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.getSourceType
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
import io.legado.app.utils.ConstraintModify
import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
import io.legado.app.utils.loadAnimation
import io.legado.app.utils.modifyBegin
import io.legado.app.utils.openUrl
import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.startActivity
import io.legado.app.utils.themeColor
import io.legado.app.utils.visible
import splitties.views.onClick
/**
* 阅读界面菜单
*/
class ReadMenu @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
var canShowMenu: Boolean = false
private val callBack: CallBack get() = activity as CallBack
private val binding = ViewReadMenuBinding.inflate(LayoutInflater.from(context), this, true)
private var confirmSkipToChapter: Boolean = false
private var isMenuOutAnimating = false
private val menuTopIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_in)
}
private val menuTopOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_top_out)
}
private val menuBottomIn: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_in)
}
private val menuBottomOut: Animation by lazy {
loadAnimation(context, R.anim.anim_readbook_bottom_out)
}
private val fadeIn = AlphaAnimation(0f, 1f).apply {
duration = 280
fillAfter = true
}
private val fadeOut = AlphaAnimation(1f, 0f).apply {
duration = 280
fillAfter = true
}
private val colorSurfaceContainer: Int
get() = context.themeColor(com.google.android.material.R.attr.colorSurfaceContainer)
private val colorSecondary: Int
get() = context.themeColor(androidx.appcompat.R.attr.colorPrimary)
private val colorSecondaryContainer: Int
get() = context.themeColor(com.google.android.material.R.attr.colorSecondaryContainer)
private val bgColor: Int
get() = when (AppConfig.readBarStyle) {
0 -> colorSurfaceContainer
1 -> runCatching {
ReadBookConfig.durConfig.curBgStr().toColorInt()
}.getOrDefault(colorSurfaceContainer)
else -> ReadBookConfig.durConfig.curMenuBg()
}
private val acColor: Int
get() = when (AppConfig.readBarStyle) {
0 -> colorSecondary
1 -> runCatching {
ReadBookConfig.durConfig.curTextColor()
}.getOrDefault(colorSecondary)
else -> ReadBookConfig.durConfig.curMenuAc()
}
private val bgcColor: Int
get() = when (AppConfig.readBarStyle) {
0 -> colorSecondaryContainer
1 -> runCatching {
val baseColor = ReadBookConfig.durConfig.curTextColor()
ColorUtils.setAlphaComponent(baseColor, (255 * 0.1f).toInt())
}.getOrDefault(colorSecondaryContainer)
else -> ColorUtils.setAlphaComponent(acColor, (255 * 0.1f).toInt())
}
private var onMenuOutEnd: (() -> Unit)? = null
private val showBrightnessView
get() = context.getPrefBoolean(
PreferKey.showBrightnessView,
true
)
private val sourceMenu by lazy {
PopupMenu(context, binding.tvSourceAction).apply {
inflate(R.menu.book_read_source)
setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_login -> callBack.showLogin()
R.id.menu_chapter_pay -> callBack.payAction()
R.id.menu_edit_source -> callBack.openSourceEditActivity()
R.id.menu_disable_source -> callBack.disableSource()
}
true
}
}
}
private val menuInListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
binding.tvSourceAction.text =
ReadBook.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
binding.tvSourceAction.isGone = ReadBook.isLocalBook
callBack.upSystemUiVisibility()
binding.llBrightness.visible(showBrightnessView)
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.vwMenuBg.setOnClickListener { runMenuOut() }
callBack.upSystemUiVisibility()
if (!LocalConfig.readMenuHelpVersionIsLast) {
callBack.showHelp()
}
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
private val menuOutListener = object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
this@ReadMenu.invisible()
binding.titleBar.invisible()
binding.bottomMenu.invisible()
canShowMenu = false
isMenuOutAnimating = false
onMenuOutEnd?.invoke()
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
}
init {
doOnAttach {
initView()
upBrightnessState()
bindEvent()
}
}
private fun initView() = binding.run {
val bgColor = this@ReadMenu.bgColor
val acColor = this@ReadMenu.acColor
val bgcColor = this@ReadMenu.bgcColor
val alphaBgColor = ColorUtils.setAlphaComponent(bgColor, (AppConfig.menuAlpha / 100f * 255).toInt())
initAnimation()
updateSliderVisibility()
val brightnessBackground = GradientDrawable()
brightnessBackground.cornerRadius = 5F.dpToPx()
llBrightness.background = brightnessBackground
// if (AppConfig.isEInkMode) {
// titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
// }
llBrightness.setOnClickListener(null)
seekBrightness.post {
seekBrightness.progress = AppConfig.readBrightness
}
if (AppConfig.showReadTitleBarAddition) {
titleBarAddition.visible()
} else {
titleBarAddition.gone()
}
binding.bottomView.post {
val allButtons = getUserButtons()
renderButtons(binding.bottomView, allButtons)
}
titleBar.setBackgroundColor(alphaBgColor)
titleBar.toolbar.setBackgroundColor(alphaBgColor)
bottomView.setBackgroundColor(alphaBgColor)
(tvPre.background as? RippleDrawable)?.setColor(ColorStateList.valueOf(bgcColor))
(tvNext.background as? RippleDrawable)?.setColor(ColorStateList.valueOf(bgcColor))
cdSlider.setCardBackgroundColor(alphaBgColor)
seekReadPage.trackInactiveTintList = ColorStateList.valueOf(bgcColor)
seekReadPage.trackActiveTintList = ColorStateList.valueOf(acColor)
seekReadPage.thumbTintList = ColorStateList.valueOf(acColor)
seekReadPage.tickActiveTintList = ColorStateList.valueOf(bgColor)
seekReadPage.tickInactiveTintList = ColorStateList.valueOf(acColor)
tvPre.iconTint = ColorStateList.valueOf(acColor)
tvNext.iconTint = ColorStateList.valueOf(acColor)
tvBookName.setTextColor(acColor)
tvChapterName.setTextColor(acColor)
tvChapterUrl.setTextColor(acColor)
tvSourceAction.setTextColor(acColor)
tvPre.backgroundTintList = ColorStateList.valueOf(bgColor)
tvNext.backgroundTintList = ColorStateList.valueOf(bgColor)
tvPre.alpha = AppConfig.menuAlpha / 100f * 255
tvNext.alpha = AppConfig.menuAlpha / 100f * 255
upBrightnessVwPos()
/**
* 确保视图不被导航栏遮挡
*/
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
binding.bottomMenu.applyNavigationBarPadding()
} else {
binding.bottomView.applyNavigationBarPadding()
}
}
fun updateToolBarColor() {
val acColor = this@ReadMenu.acColor
binding.titleBar.toolbar.navigationIcon?.setTint(acColor)
binding.titleBar.toolbar.apply {
setTitleTextColor(acColor)
setSubtitleTextColor(acColor)
}
binding.titleBar.toolbar.menu.forEach { item ->
item.icon?.setTint(acColor)
}
binding.titleBar.toolbar.overflowIcon?.setTint(acColor)
}
fun reset() {
initView()
updateToolBarColor()
upBookView()
}
fun refreshMenuColorFilter() {
// if (immersiveMenu) {
// //binding.titleBar.setColorFilter(textColor)
// }
}
fun upBrightnessState() {
if (brightnessAuto()) {
binding.ivBrightnessAuto.setColorFilter(context.themeColor(androidx.appcompat.R.attr.colorPrimary))
binding.seekBrightness.isEnabled = false
} else {
binding.ivBrightnessAuto.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorOnSurface))
binding.seekBrightness.isEnabled = true
}
setScreenBrightness(AppConfig.readBrightness.toFloat())
}
/**
* 设置屏幕亮度
*/
fun setScreenBrightness(value: Float) {
activity?.run {
var brightness = BRIGHTNESS_OVERRIDE_NONE
if (!brightnessAuto() && value != BRIGHTNESS_OVERRIDE_NONE) {
brightness = value
if (brightness < 1f) brightness = 1f
brightness /= 255f
}
val params = window.attributes
params.screenBrightness = brightness
window.attributes = params
}
}
fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
callBack.onMenuShow()
this.visible()
binding.titleBar.visible()
binding.bottomMenu.visible()
updateToolBarColor()
changeReplace(ReadBook.book?.getUseReplaceRule() ?: false)
updateBadge("replace_badge", ReadBook.curTextChapter?.effectiveReplaceRules?.size ?: 0)
if (anim) {
binding.titleBar.startAnimation(menuTopIn)
binding.bottomMenu.startAnimation(menuBottomIn)
updateBrightnessVisibility(true)
} else {
menuInListener.onAnimationStart(menuBottomIn)
menuInListener.onAnimationEnd(menuBottomIn)
}
}
fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode, onMenuOutEnd: (() -> Unit)? = null) {
if (isMenuOutAnimating) {
return
}
callBack.onMenuHide()
this.onMenuOutEnd = onMenuOutEnd
if (this.isVisible) {
if (anim) {
binding.titleBar.startAnimation(menuTopOut)
binding.bottomMenu.startAnimation(menuBottomOut)
updateBrightnessVisibility(false)
} else {
menuOutListener.onAnimationStart(menuBottomOut)
menuOutListener.onAnimationEnd(menuBottomOut)
}
}
}
fun updateBrightnessVisibility(boolean: Boolean) {
if (showBrightnessView) {
if(boolean){
binding.llBrightness.startAnimation(fadeIn)
}else{
binding.llBrightness.startAnimation(fadeOut)
}
}
}
private fun brightnessAuto(): Boolean {
return context.getPrefBoolean("brightnessAuto", true) || !showBrightnessView
}
private fun bindEvent() = binding.run {
vwMenuBg.setOnClickListener { runMenuOut() }
titleBar.toolbar.setOnClickListener {
callBack.openBookInfoActivity()
}
tvBookName.setOnClickListener {
callBack.openBookInfoActivity()
}
val chapterViewClickListener = OnClickListener {
if (ReadBook.isLocalBook) {
return@OnClickListener
}
if (AppConfig.readUrlInBrowser) {
context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
} else {
Coroutine.async {
context.startActivity<WebViewActivity> {
val url = tvChapterUrl.text.toString()
val bookSource = ReadBook.bookSource
putExtra("title", tvChapterName.text)
putExtra("url", url)
putExtra("sourceOrigin", bookSource?.bookSourceUrl)
putExtra("sourceName", bookSource?.bookSourceName)
putExtra("sourceType", bookSource?.getSourceType())
}
}
}
}
val chapterViewLongClickListener = OnLongClickListener {
if (ReadBook.isLocalBook) {
return@OnLongClickListener true
}
context.alert(R.string.open_fun) {
setMessage(R.string.use_browser_open)
okButton {
AppConfig.readUrlInBrowser = true
}
noButton {
AppConfig.readUrlInBrowser = false
}
}
true
}
tvChapterName.setOnClickListener(chapterViewClickListener)
tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
//书源操作
tvSourceAction.onClick {
sourceMenu.menu.findItem(R.id.menu_login).isVisible =
!ReadBook.bookSource?.loginUrl.isNullOrEmpty()
sourceMenu.menu.findItem(R.id.menu_chapter_pay).isVisible =
!ReadBook.bookSource?.loginUrl.isNullOrEmpty()
&& ReadBook.curTextChapter?.isVip == true
&& ReadBook.curTextChapter?.isPay != true
sourceMenu.show()
}
//亮度跟随
ivBrightnessAuto.setOnClickListener {
context.putPrefBoolean("brightnessAuto", !brightnessAuto())
upBrightnessState()
}
//亮度调节
seekBrightness.setOnSeekBarChangeListener(object : SeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (fromUser) {
setScreenBrightness(progress.toFloat())
}
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
AppConfig.readBrightness = seekBar.progress
}
})
vwBrightnessPosAdjust.setOnClickListener {
AppConfig.brightnessVwPos = !AppConfig.brightnessVwPos
upBrightnessVwPos()
}
seekReadPage.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
if (AppConfig.progressBarBehavior == "page")
ReadBook.skipToPage(value.toInt() - 1)
if (AppConfig.sliderVibrator)
HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE
}
}
seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener(null)
//VibrationUtils.vibrate(context, 16)
}
override fun onStopTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener { runMenuOut() }
val progress = slider.value.toInt()
when (AppConfig.progressBarBehavior) {
"page" -> ReadBook.skipToPage(progress - 1)
"chapter" -> {
if (confirmSkipToChapter) {
callBack.skipToChapter(progress - 1)
} else {
context.alert("章节跳转确认", "确定要跳转章节吗?") {
yesButton {
confirmSkipToChapter = true
callBack.skipToChapter(progress - 1)
}
noButton { upSeekBar() }
onCancelled { upSeekBar() }
}
}
}
}
}
})
//上一章
tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) }
//下一章
tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) }
}
private fun updateSliderVisibility() {
when (AppConfig.readSliderMode) {
"0" -> {
binding.llSlider.gravity = Gravity.CENTER
binding.llSlider.isVisible = true
binding.cdSlider.isVisible = true
binding.tvPre.isVisible = true
binding.tvNext.isVisible = true
}
"1" -> {
binding.llSlider.gravity = Gravity.CENTER
binding.llSlider.isVisible = false
binding.cdSlider.isVisible = false
binding.tvPre.isVisible = false
binding.tvNext.isVisible = false
}
"2" -> {
binding.llSlider.gravity = Gravity.START
binding.llSlider.isVisible = true
binding.cdSlider.isVisible = false
binding.tvPre.isVisible = true
binding.tvNext.isVisible = true
}
"3" -> {
binding.llSlider.gravity = Gravity.END
binding.llSlider.isVisible = true
binding.cdSlider.isVisible = false
binding.tvPre.isVisible = true
binding.tvNext.isVisible = true
}
"4" -> {
binding.llSlider.gravity = Gravity.CENTER
binding.llSlider.isVisible = true
binding.cdSlider.isVisible = true
binding.tvPre.isVisible = false
binding.tvNext.isVisible = false
}
else -> {
binding.llSlider.isVisible = true
binding.cdSlider.isVisible = true
binding.tvPre.isVisible = true
binding.tvNext.isVisible = true
}
}
}
private val buttonMap = mutableMapOf<String, MaterialButton>()
fun renderButtons(group: MaterialButtonGroup, buttons: List<ToolButton>) {
group.removeAllViews()
buttonMap.clear()
buttons.forEach { btn ->
val style = com.google.android.material.R.attr.materialIconButtonOutlinedStyle
val button = MaterialButton(group.context, null, style).apply {
id = btn.id.hashCode()
setIconResource(btn.iconRes)
contentDescription = btn.description
tooltipText = btn.description
strokeWidth = 0
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
iconTint = ColorStateList.valueOf(acColor)
val bgColorState = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(
bgcColor,
bgColor
)
)
backgroundTintList = bgColorState
maxLines = 1
if (btn.onCheck != null) {
isCheckable = true
isChecked = btn.state
setOnClickListener {
isChecked = !isChecked
btn.state = isChecked
btn.onCheck.invoke()
}
} else {
setOnClickListener { btn.onClick() }
}
btn.onLongClick?.let { longAction ->
setOnLongClickListener {
longAction()
true
}
}
}
group.addView(
button,
OverflowLinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 1f)
)
val lp =
button.layoutParams as MaterialButtonGroup.LayoutParams
lp.overflowText = btn.description
buttonMap[btn.id] = button
}
}
private val badgeMap = mutableMapOf<MaterialButton, BadgeDrawable>()
@OptIn(ExperimentalBadgeUtils::class)
fun updateBadge(id: String, count: Int) {
val btn = buttonMap[id] ?: return
if (count != 0 && btn.isChecked) {
btn.addBadge(count)
} else {
badgeMap[btn]?.let { BadgeUtils.detachBadgeDrawable(it, btn) }
badgeMap.remove(btn)
}
}
@OptIn(ExperimentalBadgeUtils::class)
private fun MaterialButton.addBadge(count: Int) {
val badgeDrawable = BadgeDrawable.create(context).apply {
number = count
backgroundColor = colorSecondary
badgeTextColor = colorSecondaryContainer
maxCharacterCount = 3
badgeGravity = BadgeDrawable.TOP_END
verticalOffset = (16).dpToPx()
}
BadgeUtils.attachBadgeDrawable(badgeDrawable, this, null)
badgeMap[this] = badgeDrawable
}
private fun getAllButtons(): List<ToolButton> {
return listOf(
ToolButton(
id = "search",
iconRes = R.drawable.ic_search,
description = context.getString(R.string.search_content),
onClick = { runMenuOut { callBack.openSearchActivity(null) } }
),
ToolButton(
id = "catalog",
iconRes = R.drawable.ic_toc,
description = context.getString(R.string.chapter_list),
onClick = { runMenuOut { callBack.openChapterList() } }
),
ToolButton(
id = "read_aloud",
iconRes = R.drawable.ic_read_aloud,
description = context.getString(R.string.read_aloud),
onClick = { runMenuOut { callBack.onClickReadAloud() } },
onLongClick = { runMenuOut { callBack.onClickReadAloud() } }
),
ToolButton(
id = "setting",
iconRes = R.drawable.ic_settings,
description = context.getString(R.string.setting),
onClick = { runMenuOut { callBack.showReadStyle() } }
),
ToolButton(
id = "addBookmark",
iconRes = R.drawable.ic_bookmark,
description = context.getString(R.string.bookmark),
onClick = { runMenuOut { callBack.addBookmark() } }
),
ToolButton(
id = "theme",
iconRes = if (AppConfig.isNightTheme) R.drawable.ic_daytime else R.drawable.ic_brightness,
description = context.getString(R.string.day_night_switch),
onClick = {
AppConfig.isNightTheme = !AppConfig.isNightTheme
OldThemeConfig.applyDayNight(context)
buttonMap["theme"]?.setIconResource(
if (AppConfig.isNightTheme) R.drawable.ic_daytime else R.drawable.ic_brightness
)
}
),
ToolButton(
id = "prev_chapter",
iconRes = R.drawable.ic_previous,
description = context.getString(R.string.previous_chapter),
onClick = { ReadBook.moveToPrevChapter(upContent = true, toLast = false) }
),
ToolButton(
id = "next_chapter",
iconRes = R.drawable.ic_next,
description = context.getString(R.string.next_chapter),
onClick = { ReadBook.moveToNextChapter(true) }
),
ToolButton(
id = "replace",
iconRes = R.drawable.ic_find_replace,
description = context.getString(R.string.replace_purify),
onLongClick = { runMenuOut { callBack.openReplaceRule() } },
onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
onClick = { }
),
ToolButton(
id = "replace_badge",
iconRes = R.drawable.ic_find_replace,
description = context.getString(R.string.replace_purify_badge),
onLongClick = { runMenuOut { callBack.openReplaceRule() } },
onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
onClick = { }
),
ToolButton(
id = "auto_page",
iconRes = R.drawable.ic_auto_page,
description = context.getString(R.string.auto_next_page),
onClick = { runMenuOut { callBack.autoPage() } }
),
ToolButton(
id = "translate",
iconRes = R.drawable.ic_translate,
description = context.getString(R.string.translate),
onClick = { runMenuOut { callBack.onTranslationClick() } },
onLongClick = { runMenuOut { callBack.onTranslationLongClick() } }
)
)
}
fun changeReplace(boolean: Boolean) {
buttonMap["replace"]?.isChecked = boolean
buttonMap["replace_badge"]?.isChecked = boolean
}
private fun getUserButtons(): List<ToolButton> {
val prefs by lazy {
context.getSharedPreferences("tool_button_config", Context.MODE_PRIVATE)
}
val allButtons = getAllButtons().associateBy { it.id }
val str = prefs.getString("tool_buttons", null)
val savedList = str?.split(";")?.mapNotNull {
val parts = it.split(",")
if (parts.size == 2) parts[0] to parts[1].toBoolean() else null
} ?: emptyList()
val result = mutableListOf<ToolButton>()
if (savedList.isNotEmpty()) {
savedList.forEach { (id, enabled) ->
if (enabled) allButtons[id]?.let { result.add(it) }
}
getAllButtons().forEach { btn ->
if (savedList.none { it.first == btn.id }) {
result.add(btn)
}
}
} else {
result.addAll(getAllButtons().take(5))
}
return result
}
fun setAutoPage(autoPage: Boolean) {
buttonMap["auto_page"]?.apply {
val icon = if (autoPage) R.drawable.ic_auto_page_stop else R.drawable.ic_auto_page
val desc =
context.getString(if (autoPage) R.string.auto_next_page_stop else R.string.auto_next_page)
setIconResource(icon)
contentDescription = desc
tooltipText = desc
}
}
fun updateTranslationButton(translationMode: Boolean) {
val btn = buttonMap["translate"] ?: return
// Update icon based on mode
if (translationMode) {
btn.setIconResource(R.drawable.ic_return)
} else {
btn.setIconResource(R.drawable.ic_translate)
}
}
private fun initAnimation() {
menuTopIn.setAnimationListener(menuInListener)
menuTopOut.setAnimationListener(menuOutListener)
}
fun upBookView() {
val bookName = ReadBook.book?.name ?: ""
val mode = AppConfig.titleBarMode?.toInt()
when (mode) {
0 -> { // 在应用栏上显示
binding.titleBar.title = bookName
binding.llBook.visible()
binding.tvBookName.gone()
}
1 -> { // 在独立行上显示
binding.titleBar.title = " "
binding.tvBookName.text = bookName
binding.llBook.visible()
binding.tvBookName.visible()
}
2 -> { // 仅显示标题
binding.titleBar.title = bookName
binding.llBook.gone()
}
3 -> { // 不显示
binding.titleBar.title = " "
binding.llBook.gone()
}
else -> {
binding.titleBar.title = " "
binding.tvBookName.text = bookName
binding.llBook.visible()
}
}
ReadBook.curTextChapter?.let {
binding.tvChapterName.text = it.title
if (!ReadBook.isLocalBook) {
binding.tvChapterUrl.text = it.chapter.getAbsoluteURL()
//binding.tvChapterUrl.visible()
} else {
binding.tvChapterUrl.gone()
}
upSeekBar()
binding.tvPre.isEnabled = ReadBook.durChapterIndex != 0
binding.tvNext.isEnabled = ReadBook.durChapterIndex != ReadBook.simulatedChapterSize - 1
} ?: run {
binding.tvChapterUrl.gone()
}
}
fun upSeekBar() = binding.seekReadPage.apply {
fun safeSet(rangeFrom: Float, rangeTo: Float, step: Float, rawValue: Float) {
valueFrom = rangeFrom
valueTo = rangeTo
stepSize = step
val safeValue = rawValue.coerceIn(rangeFrom, rangeTo)
if (value != safeValue) value = safeValue
}
when (AppConfig.progressBarBehavior) {
"page" -> {
ReadBook.curTextChapter?.let { chapter ->
if (chapter.pageSize > 0 && ReadBook.durPageIndex >= 0) {
safeSet(
rangeFrom = 1f,
rangeTo = chapter.pageSize.toFloat().coerceAtLeast(2f),
step = 1f,
rawValue = ReadBook.durPageIndex.toFloat()
)
} else {
safeSet(0f, 100000f, 0f, 0f)
}
}
}
"chapter" -> {
if (ReadBook.simulatedChapterSize > 0) {
safeSet(
rangeFrom = 1f,
rangeTo = ReadBook.simulatedChapterSize.toFloat().coerceAtLeast(2f),
step = 1f,
rawValue = ReadBook.durChapterIndex.toFloat()
)
} else {
safeSet(0f, 100000f, 0f, 0f)
}
}
}
}
// fun upSeekBar() {
// binding.seekReadPage.apply {
// when (AppConfig.progressBarBehavior) {
// "page" -> {
// ReadBook.curTextChapter?.let {
// max = it.pageSize.minus(1)
// progress = ReadBook.durPageIndex
// }
// }
//
// "chapter" -> {
// max = ReadBook.simulatedChapterSize - 1
// progress = ReadBook.durChapterIndex
// }
// }
// }
// }
fun setSeekPage(seek: Int) {
binding.seekReadPage.value = seek.toFloat() + 1
}
private fun upBrightnessVwPos() {
if (AppConfig.brightnessVwPos) {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
.rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
} else {
binding.root.modifyBegin()
.clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
.leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
.commit()
}
}
interface CallBack {
fun autoPage()
fun openReplaceRule()
fun openChapterList()
fun openSearchActivity(searchWord: String?)
fun openSourceEditActivity()
fun openBookInfoActivity()
fun showReadStyle()
fun addBookmark()
fun showReadAloudDialog()
fun upSystemUiVisibility()
fun onClickReadAloud()
fun showHelp()
fun showLogin()
fun payAction()
fun disableSource()
fun skipToChapter(index: Int)
fun onMenuShow()
fun onMenuHide()
fun changeReplaceRuleState()
fun onTranslationClick()
fun onTranslationLongClick()
}
data class ToolButton(
val id: String, // 唯一标识
val iconRes: Int, // 图标资源
val description: String, // contentDescription / tooltipText
val onClick: () -> Unit, // 点击事件
val onLongClick: (() -> Unit)? = null, // 可选长按
val onCheck: (() -> Unit)? = null,// 可选
var state: Boolean = false // 动态
)
}
@@ -1,208 +0,0 @@
package io.legado.app.ui.book.read
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.core.view.isVisible
import io.legado.app.R
import io.legado.app.databinding.ViewSearchMenuBinding
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.invisible
import io.legado.app.utils.loadAnimation
import io.legado.app.utils.visible
/**
* 搜索界面菜单
*/
class SearchMenu @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val callBack: CallBack get() = activity as CallBack
private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true)
private val menuBottomIn: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_in)
private val menuBottomOut: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_out)
private var onMenuOutEnd: (() -> Unit)? = null
private var isMenuOutAnimating = false
private val searchResultList: MutableList<SearchResult> = mutableListOf()
private var currentSearchResultIndex: Int = -1
private var lastSearchResultIndex: Int = -1
private val hasSearchResult: Boolean
get() = searchResultList.isNotEmpty()
val selectedSearchResult: SearchResult?
get() = searchResultList.getOrNull(currentSearchResultIndex)
val previousSearchResult: SearchResult?
get() = searchResultList.getOrNull(lastSearchResultIndex)
val bottomMenuVisible get() = isVisible && binding.llBottomMenu.isVisible
init {
initAnimation()
initView()
bindEvent()
updateSearchInfo()
}
fun upSearchResultList(resultList: List<SearchResult>) {
searchResultList.clear()
searchResultList.addAll(resultList)
updateSearchInfo()
}
private fun initView() = binding.run {
applyNavigationBarPadding()
}
fun runMenuIn() {
this.visible()
binding.llBottomMenu.visible()
binding.vwMenuBg.visible()
binding.llBottomMenu.startAnimation(menuBottomIn)
}
fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) {
if (isMenuOutAnimating) {
return
}
this.onMenuOutEnd = onMenuOutEnd
if (this.isVisible) {
binding.llBottomMenu.startAnimation(menuBottomOut)
}
}
@SuppressLint("SetTextI18n")
fun updateSearchInfo() {
ReadBook.curTextChapter?.let {
binding.tvCurrentChapter.text = "当前章节: ${it.title}"
}
updateSearchProgress()
}
fun updateSearchResultIndex(updateIndex: Int) {
lastSearchResultIndex = currentSearchResultIndex
currentSearchResultIndex = when {
updateIndex < 0 -> 0
updateIndex >= searchResultList.size -> searchResultList.size - 1
else -> updateIndex
}
updateSearchProgress()
}
private fun updateSearchProgress() {
val total = searchResultList.size
if (total == 0) {
binding.tvSearchProgress.text = "0%"
binding.tvSearchFraction.text = "0/0"
} else {
val current = currentSearchResultIndex + 1
val progress = (current * 100 / total)
binding.tvSearchProgress.text = "$progress%"
binding.tvSearchFraction.text = "$current / $total"
}
}
private fun bindEvent() = binding.run {
//搜索结果
ivSearchResults.setOnClickListener {
runMenuOut {
callBack.openSearchActivity(selectedSearchResult?.query)
}
}
//主菜单
ivMainMenu.setOnClickListener {
runMenuOut {
callBack.cancelSelect()
callBack.showMenuBar()
this@SearchMenu.invisible()
}
}
//退出
ivSearchExit.setOnClickListener {
runMenuOut {
callBack.exitSearchMenu()
}
}
fabLeft.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex - 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
fabRight.setOnClickListener {
updateSearchResultIndex(currentSearchResultIndex + 1)
callBack.navigateToSearch(
searchResultList[currentSearchResultIndex],
currentSearchResultIndex
)
}
}
private fun initAnimation() {
//显示菜单
menuBottomIn.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
callBack.upSystemUiVisibility()
binding.fabLeft.visible(hasSearchResult)
binding.fabRight.visible(hasSearchResult)
}
@SuppressLint("RtlHardcoded")
override fun onAnimationEnd(animation: Animation) {
binding.vwMenuBg.setOnClickListener { runMenuOut() }
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
//隐藏菜单
menuBottomOut.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
isMenuOutAnimating = true
binding.vwMenuBg.setOnClickListener(null)
}
override fun onAnimationEnd(animation: Animation) {
isMenuOutAnimating = false
binding.llBottomMenu.invisible()
binding.vwMenuBg.invisible()
binding.vwMenuBg.setOnClickListener { runMenuOut() }
onMenuOutEnd?.invoke()
callBack.upSystemUiVisibility()
}
override fun onAnimationRepeat(animation: Animation) = Unit
})
}
interface CallBack {
var isShowingSearchResult: Boolean
fun openSearchActivity(searchWord: String?)
fun showSearchSetting()
fun upSystemUiVisibility()
fun exitSearchMenu()
fun showMenuBar()
fun navigateToSearch(searchResult: SearchResult, index: Int)
fun onMenuShow()
fun onMenuHide()
fun cancelSelect()
}
}
@@ -18,11 +18,9 @@ import androidx.core.view.isVisible
import io.legado.app.R
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.ItemTextBinding
import io.legado.app.databinding.PopupActionMenuBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.gone
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.printOnDebug
@@ -34,7 +32,11 @@ import androidx.core.net.toUri
import io.legado.app.constant.AppLog
@SuppressLint("RestrictedApi")
class TextActionMenu(private val context: Context, private val callBack: CallBack) :
class TextActionMenu(
private val context: Context,
private val callBack: CallBack,
private val expandTextMenu: () -> Boolean
) :
PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context))
@@ -45,7 +47,6 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac
private val menuItems: List<MenuItemImpl>
private val visibleMenuItems = arrayListOf<MenuItemImpl>()
private val moreMenuItems = arrayListOf<MenuItemImpl>()
private val expandTextMenu get() = context.getPrefBoolean(PreferKey.expandTextMenu)
init {
@SuppressLint("InflateParams")
@@ -66,7 +67,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac
binding.recyclerView.adapter = adapter
binding.recyclerViewMore.adapter = adapter
setOnDismissListener {
if (!context.getPrefBoolean(PreferKey.expandTextMenu)) {
if (!expandTextMenu()) {
binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
binding.recyclerViewMore.gone()
adapter.setItems(visibleMenuItems)
@@ -90,7 +91,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac
}
fun upMenu() {
if (expandTextMenu) {
if (expandTextMenu()) {
adapter.setItems(menuItems)
binding.ivMenuMore.gone()
} else {
@@ -108,7 +109,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac
endX: Int,
endBottomY: Int
) {
if (expandTextMenu) {
if (expandTextMenu()) {
when {
startTopY > 500 -> {
showAtLocation(
@@ -272,4 +273,4 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac
fun onMenuActionFinally()
}
}
}
@@ -1,100 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import com.google.android.material.slider.Slider
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.databinding.DialogAutoReadBinding
import io.legado.app.help.config.ReadBookConfig
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import io.legado.app.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.BaseReadBookActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import java.util.Locale
class AutoReadDialog : BaseBottomSheetDialogFragment(R.layout.dialog_auto_read) {
private val binding by viewBinding(DialogAutoReadBinding::bind)
private val callBack: CallBack? get() = activity as? CallBack
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run {
val bottomDialog = (activity as ReadBookActivity).bottomDialog++
if (bottomDialog > 0) {
dismiss()
return@run
}
initOnChange()
initData()
initEvent()
}
private fun initData() {
val speed = if (ReadBookConfig.autoReadSpeed < 1) 1 else ReadBookConfig.autoReadSpeed
binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed)
binding.seekAutoRead.value = speed.toFloat()
}
private fun initOnChange() {
binding.seekAutoRead.addOnChangeListener { slider, value, fromUser ->
val speed = if (value < 1) 1 else value.toInt()
binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed)
}
binding.seekAutoRead.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
}
override fun onStopTrackingTouch(slider: Slider) {
ReadBookConfig.autoReadSpeed = if (slider.value < 1) 1 else slider.value.toInt()
upTtsSpeechRate()
}
})
}
private fun initEvent() {
binding.btnMainMenu.setOnClickListener {
callBack?.showMenuBar()
dismissAllowingStateLoss()
}
binding.btnSetting.setOnClickListener {
(activity as BaseReadBookActivity).showPageAnimConfig {
(activity as ReadBookActivity).upPageAnim()
ReadBook.loadContent(false)
}
}
binding.btnCatalog.setOnClickListener { callBack?.openChapterList() }
binding.btnAutoPageStop.setOnClickListener {
callBack?.autoPageStop()
binding.btnAutoPageStop.post {
dismissAllowingStateLoss()
}
}
}
private fun upTtsSpeechRate() {
ReadAloud.upTtsSpeechRate(requireContext())
if (!BaseReadAloudService.pause) {
ReadAloud.pause(requireContext())
ReadAloud.resume(requireContext())
}
}
interface CallBack {
fun showMenuBar()
fun openChapterList()
fun autoPageStop()
}
}
@@ -1,48 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.Context
import android.view.ViewGroup
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.EventBus
import io.legado.app.databinding.ItemBgImageBinding
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.glide.ImageLoader
import io.legado.app.utils.postEvent
import java.io.File
class BgAdapter(context: Context, val textColor: Int) :
RecyclerAdapter<String, ItemBgImageBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBgImageBinding {
return ItemBgImageBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBgImageBinding,
item: String,
payloads: MutableList<Any>
) {
binding.run {
tvName.text = item.substringBeforeLast(".")
ImageLoader.load(
context,
context.assets.open("bg${File.separator}$item").readBytes()
)
.centerCrop()
.into(ivBg)
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemBgImageBinding) {
holder.itemView.apply {
this.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let {
ReadBookConfig.durConfig.setCurBg(1, it)
postEvent(EventBus.UP_CONFIG, arrayListOf(1))
}
}
}
}
}
@@ -0,0 +1,161 @@
package io.legado.app.ui.book.read.config
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Shader
import android.text.style.ReplacementSpan
import io.legado.app.ui.book.read.page.entities.TextLine
import io.legado.app.utils.dpToPx
/**
* 背景图+下划线 Span用于高亮规则匹配区域
*/
class BgImageSpan(
private val textColor: Int,
private val bgImagePath: String,
private val bgImageFit: Int = 0,
private val bgImageScale: Float = 1f,
private val underlineMode: Int = 0,
private val underlineColor: Int = 0,
private val underlineWidth: Float = 1f,
private val underlineSvgPath: String = "",
private val underlineOffset: Float = 6f,
) : ReplacementSpan() {
private val offsetPx = underlineOffset.toInt().dpToPx()
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if (fm != null) {
val metrics = paint.fontMetricsInt
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent + if (underlineMode != 0) offsetPx else 0
fm.bottom = metrics.bottom + if (underlineMode != 0) offsetPx else 0
}
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val width = paint.measureText(text, start, end)
val rectWidth = width
val rectHeight = (bottom - top).toFloat()
val scale = bgImageScale.coerceIn(0.1f, 5f)
val bitmap = TextLine.getBgBitmap(bgImagePath)
if (bitmap != null) {
val bgPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
isFilterBitmap = true
}
when (bgImageFit) {
1 -> {
val sw = rectWidth * scale
val sh = rectHeight * scale
val dx = x + (rectWidth - sw) / 2f
val dy = top + (rectHeight - sh) / 2f
canvas.save()
canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat())
canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + sw, dy + sh), bgPaint)
canvas.restore()
}
2 -> {
val bw = bitmap.width.toFloat()
val bh = bitmap.height.toFloat()
val fitScale = (rectWidth / bw).coerceAtLeast(rectHeight / bh) * scale
val scaledW = bw * fitScale
val scaledH = bh * fitScale
val dx = x + (rectWidth - scaledW) / 2f
val dy = top + (rectHeight - scaledH) / 2f
canvas.save()
canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat())
canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + scaledW, dy + scaledH), bgPaint)
canvas.restore()
}
else -> {
val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
val matrix = Matrix()
if (scale != 1f) {
matrix.setScale(scale, scale)
}
matrix.postTranslate(x, top.toFloat())
shader.setLocalMatrix(matrix)
bgPaint.shader = shader
canvas.drawRect(x, top.toFloat(), x + width, bottom.toFloat(), bgPaint)
}
}
}
paint.color = textColor
paint.shader = null
canvas.drawText(text, start, end, x, y.toFloat(), paint)
if (underlineMode != 0) {
drawUnderline(canvas, x, x + width, y + offsetPx, paint)
}
}
private fun drawUnderline(canvas: Canvas, startX: Float, endX: Float, lineY: Int, paint: Paint) {
val ulPaint = Paint(paint).apply {
color = underlineColor
style = Paint.Style.STROKE
strokeWidth = underlineWidth.dpToPx()
isAntiAlias = true
}
when (underlineMode) {
1 -> canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
2 -> {
ulPaint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(10f, 10f), 0f)
canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
}
3 -> {
val path = android.graphics.Path()
val waveAmplitude = 3.dpToPx().toFloat()
val waveLength = 12.dpToPx().toFloat()
path.moveTo(startX, lineY.toFloat())
var currentX = startX
val endY = lineY.toFloat()
while (currentX < endX) {
val nextX = (currentX + waveLength).coerceAtMost(endX)
val midX = (currentX + nextX) / 2
path.quadTo(midX, endY - waveAmplitude, nextX, endY)
currentX = nextX
if (currentX < endX) {
val nextX2 = (currentX + waveLength).coerceAtMost(endX)
val midX2 = (currentX + nextX2) / 2
path.quadTo(midX2, endY + waveAmplitude, nextX2, endY)
currentX = nextX2
}
}
canvas.drawPath(path, ulPaint)
}
4 -> {
val lineGap = 3.dpToPx()
val line2Y = lineY + lineGap + underlineWidth.dpToPx()
canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
canvas.drawLine(startX, line2Y.toFloat(), endX, line2Y.toFloat(), ulPaint)
}
}
}
}
@@ -1,368 +0,0 @@
package io.legado.app.ui.book.read.config
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.TooltipCompat
import androidx.core.graphics.toColorInt
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.slider.Slider
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogReadBgTextBinding
import io.legado.app.databinding.ItemBgImageBinding
import io.legado.app.help.DefaultData
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
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.ui.book.read.ReadBookActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.SelectImageContract
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.createFileReplace
import io.legado.app.utils.createFolderReplace
import io.legado.app.utils.delete
import io.legado.app.utils.externalCache
import io.legado.app.utils.externalFiles
import io.legado.app.utils.find
import io.legado.app.utils.getFile
import io.legado.app.utils.inputStream
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.launch
import io.legado.app.utils.longToast
import io.legado.app.utils.openInputStream
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.outputStream
import io.legado.app.utils.parseToUri
import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.readBytes
import io.legado.app.utils.readUri
import io.legado.app.utils.stackTraceStr
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import splitties.init.appCtx
import java.io.File
import java.io.FileOutputStream
class BgTextConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_bg_text) {
companion object {
const val BG_COLOR = 122
}
private val binding by viewBinding(DialogReadBgTextBinding::bind)
private val configFileName = "readConfig.zip"
private val adapter by lazy { BgAdapter(requireContext(), secondaryTextColor) }
private var secondaryTextColor = 0
private val importFormNet = "网络导入"
private val selectBgImage = registerForActivityResult(SelectImageContract()) {
it.uri?.let { uri ->
setBgFromUri(uri)
}
}
private val selectExportDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
exportConfig(uri)
}
}
private val selectImportDoc = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
if (uri.toString() == importFormNet) {
importNetConfigAlert()
} else {
importConfig(uri)
}
}
}
override fun onStart() {
super.onStart()
dialog?.window?.run {
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
initView()
initData()
initEvent()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
(activity as ReadBookActivity).bottomDialog--
}
private fun initView() = binding.run {
recyclerView.adapter = adapter
adapter.addHeaderView {
ItemBgImageBinding.inflate(layoutInflater, it, false).apply {
tvName.text = getString(R.string.select_image)
ivBg.setImageResource(R.drawable.ic_add)
root.setOnClickListener {
selectBgImage.launch()
}
}
}
requireContext().assets.list("bg")?.let {
adapter.setItems(it.toList())
}
}
@SuppressLint("InflateParams")
private fun initData() = with(ReadBookConfig.durConfig) {
binding.tvName.text = name.ifBlank { "文字" }
binding.swDarkStatusIcon.isChecked = curStatusIconDark()
binding.sbBgAlpha.value = ReadBookConfig.bgAlpha.toFloat()
binding.dottedRatio.valueFormat = {
(ReadBookConfig.dottedRatio * 100).toInt().toString()
}
binding.dottedBase.valueFormat = {
(ReadBookConfig.dottedBase * 100).toInt().toString()
}
}
@SuppressLint("InflateParams")
private fun initEvent() = with(ReadBookConfig.durConfig) {
binding.ivEdit.setOnClickListener {
alert(R.string.style_name) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "name"
editView.setText(ReadBookConfig.durConfig.name)
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let {
binding.tvName.text = it
ReadBookConfig.durConfig.name = it
}
}
cancelButton()
}
}
binding.tvRestore.setOnClickListener {
val defaultConfigs = DefaultData.readConfigs
val layoutNames = defaultConfigs.map { it.name }
context?.selector("选择预设布局", layoutNames) { _, i ->
if (i >= 0) {
ReadBookConfig.durConfig = defaultConfigs[i].copy()
initData()
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
}
}
}
binding.swDarkStatusIcon.setOnCheckedChangeListener { _, isChecked ->
setCurStatusIconDark(isChecked)
(activity as? ReadBookActivity)?.upSystemUiVisibility()
}
binding.tvBgColor.setOnClickListener {
val bgColor =
if (curBgType() == 0) curBgStr().toColorInt()
else "#015A86".toColorInt()
ColorPickerDialog.newBuilder()
.setColor(bgColor)
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(BG_COLOR)
.show(requireActivity())
}
binding.tvBgColor.apply {
TooltipCompat.setTooltipText(this, text)
}
binding.ivImport.setOnClickListener {
selectImportDoc.launch {
mode = HandleFileContract.FILE
title = getString(R.string.import_str)
allowExtensions = arrayOf("zip")
otherActions = arrayListOf(SelectItem(importFormNet, -1))
}
}
binding.ivExport.setOnClickListener {
selectExportDir.launch {
title = getString(R.string.export_str)
}
}
binding.ivDelete.setOnClickListener {
if (ReadBookConfig.deleteDur()) {
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
dismissAllowingStateLoss()
} else {
toastOnUi("数量已是最少,不能删除.")
}
}
binding.sbBgAlpha.addOnChangeListener { slider, value, fromUser ->
ReadBookConfig.bgAlpha = value.toInt()
postEvent(EventBus.UP_CONFIG, arrayListOf(3))
}
binding.sbBgAlpha.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
// 可留空
}
override fun onStopTrackingTouch(slider: Slider) {
postEvent(EventBus.UP_CONFIG, arrayListOf(3))
}
})
binding.dottedRatio.onChanged = {
ReadBookConfig.dottedRatio = it / 100f
postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11))
}
binding.dottedBase.onChanged = {
ReadBookConfig.dottedBase = it / 100f
postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11))
}
}
private fun exportConfig(uri: Uri) {
val exportFileName = if (ReadBookConfig.config.name.isBlank()) {
configFileName
} else {
"${ReadBookConfig.config.name}.zip"
}
execute {
val exportFiles = arrayListOf<File>()
val configDir = requireContext().externalCache.getFile("readConfig")
configDir.createFolderReplace()
val configFile = configDir.getFile("readConfig.json")
configFile.createFileReplace()
val config = ReadBookConfig.getExportConfig()
val fontPath = ReadBookConfig.textFont
if (fontPath.isNotEmpty()) {
val fontDoc = FileDoc.fromFile(fontPath)
val fontName = fontDoc.name
val fontInputStream = fontDoc.openInputStream().getOrNull()
fontInputStream?.use {
val fontExportFile = FileUtils.createFileIfNotExist(configDir, fontName)
fontExportFile.outputStream().use { out ->
it.copyTo(out)
}
config.textFont = fontName
exportFiles.add(fontExportFile)
}
}
configFile.writeText(GSON.toJson(config))
exportFiles.add(configFile)
repeat(3) {
val path = ReadBookConfig.durConfig.getBgPath(it) ?: return@repeat
val bgExportFile = copyBgImage(path, configDir) ?: return@repeat
exportFiles.add(bgExportFile)
}
val configZipPath = FileUtils.getPath(requireContext().externalCache, configFileName)
if (ZipUtils.zipFiles(exportFiles, File(configZipPath))) {
val exportDir = FileDoc.fromDir(uri)
exportDir.find(exportFileName)?.delete()
val exportFileDoc = exportDir.createFileIfNotExist(exportFileName)
exportFileDoc.openOutputStream().getOrThrow().use { out ->
File(configZipPath).inputStream().use {
it.copyTo(out)
}
}
}
}.onSuccess {
toastOnUi("导出成功, 文件名为 $exportFileName")
}.onError {
it.printOnDebug()
AppLog.put("导出失败:${it.localizedMessage}", it)
longToast("导出失败:${it.localizedMessage}")
}
}
private fun copyBgImage(path: String, configDir: File): File? {
val bgName = FileUtils.getName(path)
val bgFile = File(path)
if (bgFile.exists()) {
val bgExportFile = File(FileUtils.getPath(configDir, bgName))
if (!bgExportFile.exists()) {
bgFile.copyTo(bgExportFile)
return bgExportFile
}
}
return null
}
@SuppressLint("InflateParams")
private fun importNetConfigAlert() {
alert("输入地址") {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater)
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let { url ->
importNetConfig(url)
}
}
cancelButton()
}
}
private fun importNetConfig(url: String) {
execute {
okHttpClient.newCallResponseBody {
url(url)
}.bytes().let {
importConfig(it)
}
}.onError {
longToast(it.stackTraceStr)
}
}
private fun importConfig(uri: Uri) {
execute {
importConfig(uri.readBytes(requireContext()))
}.onError {
it.printOnDebug()
longToast("导入失败:${it.localizedMessage}")
}
}
private fun importConfig(byteArray: ByteArray) {
execute {
ReadBookConfig.import(byteArray)
}.onSuccess {
ReadBookConfig.durConfig = it
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
toastOnUi("导入成功")
}.onError {
it.printOnDebug()
longToast("导入失败:${it.localizedMessage}")
}
}
private fun setBgFromUri(uri: Uri) {
readUri(uri) { fileDoc, inputStream ->
kotlin.runCatching {
var file = requireContext().externalFiles
val suffix = fileDoc.name.substringAfterLast(".")
val fileName = uri.inputStream(requireContext()).getOrThrow().use {
MD5Utils.md5Encode(it) + ".$suffix"
}
file = FileUtils.createFileIfNotExist(file, "bg", fileName)
FileOutputStream(file).use { outputStream ->
inputStream.copyTo(outputStream)
}
ReadBookConfig.durConfig.setCurBg(2, fileName)
postEvent(EventBus.UP_CONFIG, arrayListOf(1))
}.onFailure {
appCtx.toastOnUi(it.localizedMessage)
}
}
}
}
@@ -1,150 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import io.legado.app.R
import io.legado.app.base.BaseOverlayDialogFragment
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.DialogClickActionConfigBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.selector
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.putPrefInt
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 点击区域设置
*/
class ClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) {
private val binding by viewBinding(DialogClickActionConfigBinding::bind)
private val actions by lazy {
linkedMapOf(
Pair(-1, getString(R.string.non_action)),
Pair(0, getString(R.string.menu)),
Pair(1, getString(R.string.next_page)),
Pair(2, getString(R.string.prev_page)),
Pair(3, getString(R.string.next_chapter)),
Pair(4, getString(R.string.previous_chapter)),
Pair(5, getString(R.string.read_aloud_prev_paragraph)),
Pair(6, getString(R.string.read_aloud_next_paragraph)),
Pair(7, getString(R.string.bookmark_add)),
Pair(8, getString(R.string.edit_content)),
Pair(9, getString(R.string.replace_state_change)),
Pair(10, getString(R.string.chapter_list)),
Pair(11, getString(R.string.search_content)),
Pair(12, getString(R.string.sync_book_progress_t)),
Pair(13, getString(R.string.read_aloud_pause_resume))
)
}
override fun onStart() {
super.onStart()
dialog?.window?.run {
setBackgroundDrawableResource(R.color.transparent)
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
view.setBackgroundColor(getCompatColor(R.color.translucent))
initData()
initViewEvent()
}
private fun initData() = binding.run {
tvTopLeft.text = actions[AppConfig.clickActionTL]
tvTopCenter.text = actions[AppConfig.clickActionTC]
tvTopRight.text = actions[AppConfig.clickActionTR]
tvMiddleLeft.text = actions[AppConfig.clickActionML]
tvMiddleCenter.text = actions[AppConfig.clickActionMC]
tvMiddleRight.text = actions[AppConfig.clickActionMR]
tvBottomLeft.text = actions[AppConfig.clickActionBL]
tvBottomCenter.text = actions[AppConfig.clickActionBC]
tvBottomRight.text = actions[AppConfig.clickActionBR]
}
private fun initViewEvent() {
binding.ivClose.setOnClickListener {
dismissAllowingStateLoss()
}
binding.tvTopLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvTopRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionTR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionML, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionMC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvMiddleRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionMR, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomLeft.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBL, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomCenter.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBC, action)
(it as? TextView)?.text = actions[action]
}
}
binding.tvBottomRight.setOnClickListener {
selectAction { action ->
putPrefInt(PreferKey.clickActionBR, action)
(it as? TextView)?.text = actions[action]
}
}
}
private fun selectAction(success: (action: Int) -> Unit) {
context?.selector(
getString(R.string.select_action),
actions.values.toList()
) { _, index ->
success.invoke(actions.keys.toList()[index])
}
}
override fun onDestroy() {
super.onDestroy()
AppConfig.detectClickArea()
}
}
@@ -0,0 +1,64 @@
package io.legado.app.ui.book.read.config
import android.graphics.Canvas
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.text.style.ReplacementSpan
import io.legado.app.utils.dpToPx
/**
* 虚线下划线 Span
*/
class DashUnderlineSpan(
private val textColor: Int,
private val underlineColor: Int,
private val underlineWidth: Float = 1f,
private val underlineOffset: Float = 6f,
) : ReplacementSpan() {
private val offsetPx = underlineOffset.toInt().dpToPx()
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if (fm != null) {
val metrics = paint.fontMetricsInt
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent + offsetPx
fm.bottom = metrics.bottom + offsetPx
}
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val textStr = text.subSequence(start, end).toString()
paint.color = textColor
canvas.drawText(textStr, x, y.toFloat(), paint)
val width = paint.measureText(text, start, end)
val lineY = y + offsetPx
val dashPaint = Paint(paint).apply {
color = underlineColor
style = Paint.Style.STROKE
strokeWidth = underlineWidth.dpToPx()
pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f)
isAntiAlias = true
}
canvas.drawLine(x, lineY.toFloat(), x + width, lineY.toFloat(), dashPaint)
}
}
@@ -0,0 +1,66 @@
package io.legado.app.ui.book.read.config
import android.graphics.Canvas
import android.graphics.Paint
import android.text.style.ReplacementSpan
import io.legado.app.utils.dpToPx
/**
* 双线下划线 Span
*/
class DoubleUnderlineSpan(
private val textColor: Int,
private val underlineColor: Int,
private val underlineWidth: Float = 1f,
private val underlineOffset: Float = 6f,
) : ReplacementSpan() {
private val offsetPx = underlineOffset.toInt().dpToPx()
private val lineGap = 3.dpToPx()
private val widthPx = underlineWidth.toInt().dpToPx()
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if (fm != null) {
val metrics = paint.fontMetricsInt
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent + offsetPx + lineGap + widthPx
fm.bottom = metrics.bottom + offsetPx + lineGap + widthPx
}
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val textStr = text.subSequence(start, end).toString()
paint.color = textColor
canvas.drawText(textStr, x, y.toFloat(), paint)
val width = paint.measureText(text, start, end)
val line1Y = y + offsetPx
val line2Y = line1Y + lineGap + widthPx
val linePaint = Paint(paint).apply {
color = underlineColor
style = Paint.Style.STROKE
strokeWidth = underlineWidth.dpToPx()
isAntiAlias = true
}
canvas.drawLine(x, line1Y.toFloat(), x + width, line1Y.toFloat(), linePaint)
canvas.drawLine(x, line2Y.toFloat(), x + width, line2Y.toFloat(), linePaint)
}
}
@@ -1,206 +0,0 @@
package io.legado.app.ui.book.read.config
import android.os.Bundle
import android.view.View
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogFontConfigBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadBookConfig.underline
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.observeEvent
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 字体选择对话框
*/
class FontConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_config) {
companion object {
const val S_COLOR = 123
const val TEXT_COLOR = 121
const val TEXT_ACCENT_COLOR = 125
}
private val binding by viewBinding(DialogFontConfigBinding::bind)
private val callBack2 get() = activity as? ReadBookActivity
private val weightIconMap = mapOf(
0 to R.drawable.ic_text_weight_0,
1 to R.drawable.ic_text_weight_1,
2 to R.drawable.ic_text_weight_2,
)
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) { list ->
if (list.contains(2)) {
binding.btnTextColor.color = ReadBookConfig.durConfig.curTextColor()
binding.btnShadowColor.color = ReadBookConfig.durConfig.curTextShadowColor()
binding.btnTextAccentColor.color = ReadBookConfig.durConfig.curTextAccentColor()
}
}
initView()
upView()
initViewEvent()
}
private fun initView() = binding.run {
binding.btnTextColor.color = ReadBookConfig.durConfig.curTextColor()
binding.btnShadowColor.color = ReadBookConfig.durConfig.curTextShadowColor()
binding.btnTextAccentColor.color = ReadBookConfig.durConfig.curTextAccentColor()
binding.swUnderline.isChecked = underline
dsbTextLetterSpacing.valueFormat = {
((it - 50) / 100f).toString()
}
dsbLineSize.valueFormat = { ((it - 10) / 10f).toString() }
binding.dsbParagraphSpacing.valueFormat = { value ->
(value / 10f).toString()
}
binding.btnIndentLayout.apply {
valueFormat = { value ->
value.toString()
}
onChanged = { value ->
val indentCount = value.coerceIn(0, 4)
ReadBookConfig.paragraphIndent = " ".repeat(indentCount)
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
progress = ReadBookConfig.paragraphIndent.length
}
val weightOptions = context?.resources?.getStringArray(R.array.text_font_weight)
val weightValues = listOf(0, 1, 2)
val initialIndex = weightValues.indexOf(ReadBookConfig.textBold)
val initialIconRes = weightIconMap[initialIndex] ?: R.drawable.ic_custom_text
binding.textFontWeightConverter.setIconResource(initialIconRes)
binding.textFontWeightConverter.setOnClickListener {
context?.alert(titleResource = R.string.text_font_weight_converter) {
weightOptions?.let { options ->
items(options.toList()) { _, i ->
ReadBookConfig.textBold = weightValues[i]
binding.sliderFontWeight.progress =
ReadBookConfig.textBold.coerceAtLeast(100)
val iconRes = weightIconMap[i] ?: R.drawable.ic_custom_text
binding.textFontWeightConverter.setIconResource(iconRes)
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6))
}
}
}
}
binding.sliderFontWeight.apply {
min = 100
max = 900
progress = ReadBookConfig.textBold.coerceAtLeast(100)
onChanged = {
binding.textFontWeightConverter.setIconResource(R.drawable.ic_custom_text)
ReadBookConfig.textBold = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6))
}
}
binding.btnShadowSet.setOnClickListener {
callBack2?.showShadowSet()
}
binding.btnRegexColor.setOnClickListener {
RegexColorConfigDialog().show(childFragmentManager, "regexColorConfig")
}
binding.btnSelectFonts.setOnClickListener {
callBack2?.showFontSelect()
}
binding.btnTextItalic.isChecked = ReadBookConfig.textItalic
binding.btnTextShadow.isChecked = ReadBookConfig.textShadow
binding.btnShadowColor.color = ReadBookConfig.textShadowColor
}
private fun initViewEvent() = binding.run {
dsbTextLetterSpacing.onChanged = {
ReadBookConfig.letterSpacing = (it - 50) / 100f
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
dsbLineSize.onChanged = {
ReadBookConfig.lineSpacingExtra = it
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.btnTextColor.setOnClickListener {
ColorPickerDialog.newBuilder()
.setColor(ReadBookConfig.durConfig.curTextColor())
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TEXT_COLOR)
.show(requireActivity())
}
binding.btnTextAccentColor.setOnClickListener {
ColorPickerDialog.newBuilder()
.setColor(ReadBookConfig.durConfig.curTextAccentColor())
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TEXT_ACCENT_COLOR)
.show(requireActivity())
}
binding.swUnderline.addOnCheckedChangeListener { _, isChecked ->
callBack2?.showUnderlineConfig()
}
binding.btnDefaultFonts.setOnClickListener {
val requireContext = requireContext()
alert(titleResource = R.string.system_typeface) {
items(
requireContext.resources.getStringArray(R.array.system_typefaces).toList()
) { _, i ->
AppConfig.systemTypefaces = i
onDefaultFontChange()
}
}
}
binding.dsbParagraphSpacing.onChanged = { value ->
ReadBookConfig.paragraphSpacing = value
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.btnTextItalic.addOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.textItalic = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.btnTextShadow.addOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.textShadow = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.btnShadowColor.setOnClickListener {
ColorPickerDialog.newBuilder()
.setColor(ReadBookConfig.config.curTextShadowColor())
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(S_COLOR)
.show(requireActivity())
//postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
private fun upView() = binding.run {
ReadBookConfig.let {
dsbTextLetterSpacing.progress = (it.letterSpacing * 100).toInt() + 50
dsbLineSize.progress = it.lineSpacingExtra
dsbParagraphSpacing.progress = it.paragraphSpacing
}
}
private fun onDefaultFontChange() {
callBack?.selectFont("")
}
private val callBack: CallBack?
get() = (parentFragment as? CallBack) ?: (activity as? CallBack)
interface CallBack {
fun selectFont(path: String)
val curFontPath: String
}
}
@@ -1,258 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.net.toUri
import androidx.core.widget.addTextChangedListener
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.DialogFontSelectBinding
import io.legado.app.databinding.ItemFontBinding
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.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.RealPathUtil
import io.legado.app.utils.cnCompare
import io.legado.app.utils.externalFiles
import io.legado.app.utils.getPrefString
import io.legado.app.utils.invisible
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.list
import io.legado.app.utils.listFileDocs
import io.legado.app.utils.printOnDebug
import io.legado.app.utils.putPrefString
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.launch
import java.io.File
import java.net.URLDecoder
class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_select) {
private val fontRegex = Regex("(?i).*\\.[ot]tf")
private val binding by viewBinding(DialogFontSelectBinding::bind)
private val adapter by lazy {
val curFontPath = callBack?.curFontPath ?: ""
FontAdapter(requireContext(), curFontPath)
}
private var allFontItems: List<FileDoc> = emptyList()
private val selectFontDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
if (uri.isContentScheme()) {
putPrefString(PreferKey.fontFolder, uri.toString())
val doc = DocumentFile.fromTreeUri(requireContext(), uri)
if (doc != null) {
loadFontFiles(FileDoc.fromDocumentFile(doc))
} else {
RealPathUtil.getPath(requireContext(), uri)?.let { path ->
loadFontFilesByPermission(path)
}
}
} else {
uri.path?.let { path ->
putPrefString(PreferKey.fontFolder, path)
loadFontFilesByPermission(path)
}
}
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerView.layoutManager = GridLayoutManager(context, 2)
binding.recyclerView.adapter = adapter
initView()
initSearch()
}
private fun initSearch() {
binding.etSearch.addTextChangedListener {
filterFonts(it.toString())
}
}
private fun filterFonts(keyword: String) {
val filtered = if (keyword.isBlank()) {
allFontItems
} else {
allFontItems.filter { it.name.contains(keyword, ignoreCase = true) }
}
adapter.setItems(filtered)
}
private fun initView() {
val fontPath = getPrefString(PreferKey.fontFolder)
if (fontPath.isNullOrEmpty()) {
openFolder()
} else {
if (fontPath.isContentScheme()) {
val doc = DocumentFile.fromTreeUri(requireContext(), fontPath.toUri())
if (doc?.canRead() == true) {
loadFontFiles(FileDoc.fromDocumentFile(doc))
} else {
openFolder()
}
} else {
loadFontFilesByPermission(fontPath)
}
}
binding.btnOtherDir.setOnClickListener {
openFolder()
}
}
private fun openFolder() {
lifecycleScope.launch {
val defaultPath = "SD${File.separator}Fonts"
selectFontDir.launch {
otherActions = arrayListOf(SelectItem(defaultPath, -1))
}
}
}
private fun getLocalFonts(): ArrayList<FileDoc> {
val path = FileUtils.getPath(requireContext().externalFiles, "font")
return File(path).listFileDocs {
it.name.matches(fontRegex)
}
}
private fun loadFontFilesByPermission(path: String) {
PermissionsCompat.Builder()
.addPermissions(*Permissions.Group.STORAGE)
.rationale(R.string.tip_perm_request_storage)
.onGranted {
loadFontFiles(
FileDoc.fromFile(File(path))
)
}
.request()
}
private fun loadFontFiles(fileDoc: FileDoc) {
execute {
val fontItems = fileDoc.list {
it.name.matches(fontRegex)
} ?: ArrayList()
mergeFontItems(fontItems, getLocalFonts())
}.onSuccess {
allFontItems = it
adapter.setItems(it)
}.onError {
AppLog.put("加载字体文件失败\n${it.localizedMessage}", it)
toastOnUi("getFontFiles:${it.localizedMessage}")
}
}
private fun mergeFontItems(
items1: ArrayList<FileDoc>,
items2: ArrayList<FileDoc>
): List<FileDoc> {
val items = ArrayList(items1)
items2.forEach { item2 ->
var isInFirst = false
items1.forEach for1@{ item1 ->
if (item2.name == item1.name) {
isInFirst = true
return@for1
}
}
if (!isInFirst) {
items.add(item2)
}
}
return items.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}
}
fun onFontSelect(docItem: FileDoc) {
execute {
callBack?.selectFont(docItem.toString())
}.onSuccess {
dismissAllowingStateLoss()
}
}
private fun onDefaultFontChange() {
callBack?.selectFont("")
}
var explicitCallback: CallBack? = null
private val callBack: CallBack?
get() = explicitCallback ?: (parentFragment as? CallBack) ?: (activity as? CallBack)
inner class FontAdapter(context: Context, curFilePath: String) :
RecyclerAdapter<FileDoc, ItemFontBinding>(context) {
private val curName = runCatching {
URLDecoder.decode(curFilePath, "utf-8")
}.getOrNull()?.substringAfterLast(File.separator)
override fun getViewBinding(parent: ViewGroup): ItemFontBinding {
return ItemFontBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemFontBinding,
item: FileDoc,
payloads: MutableList<Any>
) {
binding.run {
runCatching {
val typeface: Typeface? = if (item.isContentScheme) {
context.contentResolver
.openFileDescriptor(item.uri, "r")?.use {
Typeface.Builder(it.fileDescriptor).build()
}
} else {
Typeface.createFromFile(item.uri.path!!)
}
tvFont.typeface = typeface
}.onFailure {
it.printOnDebug()
AppLog.put("读取字体 ${item.name} 出错\n${it.localizedMessage}", it, true)
}
tvFont.text = item.name
root.setOnClickListener { onFontSelect(item) }
if (item.name == curName) {
ivChecked.visible()
} else {
ivChecked.invisible()
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemFontBinding) {
holder.itemView.setOnClickListener {
getItem(holder.layoutPosition)?.let {
onFontSelect(it)
}
}
}
}
interface CallBack {
fun selectFont(path: String)
val curFontPath: String
}
}
@@ -0,0 +1,356 @@
package io.legado.app.ui.book.read.config
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.HighlightRule
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefString
import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.putPrefString
import splitties.init.appCtx
import java.io.File
object HighlightRuleStore {
const val backupFileName = "highlightRule.json"
data class BackupData(
val rules: List<HighlightRule> = emptyList(),
val dialogEnabled: Boolean = true,
val bookTitleEnabled: Boolean = true,
val bracketNoteEnabled: Boolean = true,
)
private val dao get() = appDb.highlightRuleDao
fun load(): List<HighlightRule> {
migrateFromPrefsIfNeeded()
return dao.getAll()
}
fun loadEnabled(): List<HighlightRule> {
migrateFromPrefsIfNeeded()
return dao.getEnabled()
}
fun save(rules: List<HighlightRule>) {
val sanitized = rules.mapIndexed { index, rule ->
sanitizeRule(rule).copy(position = index)
}
dao.replaceAll(sanitized)
cleanupUnusedBgImages(sanitized)
}
fun update(rule: HighlightRule) {
dao.update(sanitizeRule(rule))
}
fun delete(rule: HighlightRule) {
dao.delete(rule)
}
fun reset(): List<HighlightRule> {
val defaults = createDefaultRules()
dao.replaceAll(defaults)
return defaults
}
fun createBackupData(): BackupData {
return BackupData(
rules = load(),
dialogEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleDialog, true),
bookTitleEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBookTitle, true),
bracketNoteEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBracketNote, true),
)
}
fun restoreBackupData(backupData: BackupData, backupRootPath: String? = null) {
val rules = backupData.rules.map { rule ->
val safeRule = sanitizeRule(rule)
val restoredBgImage = restoreRuleBgImage(backupRootPath, safeRule.bgImage)
safeRule.copy(bgImage = restoredBgImage)
}
save(rules)
appCtx.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled)
appCtx.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled)
}
/**
* 从旧版 SharedPreferences 迁移数据一次性
*/
private fun migrateFromPrefsIfNeeded() {
if (dao.count() > 0) return
// 尝试从 SharedPreferences 读取旧数据
val stored = appCtx.getPrefString(PreferKey.highlightRuleItems)
if (!stored.isNullOrBlank()) {
val oldRules = GSON.fromJsonArray<LegacyHighlightRule>(stored).getOrNull()
if (!oldRules.isNullOrEmpty()) {
val migrated = oldRules.mapIndexed { index, old ->
sanitizeRule(
HighlightRule(
id = old.id,
name = old.name,
pattern = old.pattern,
sampleText = old.sampleText,
targetScope = old.targetScope,
enabled = old.enabled,
position = index,
textColor = old.textColor,
underlineMode = old.underlineMode,
underlineColor = old.underlineColor,
underlineWidth = old.underlineWidth,
underlineOffset = old.underlineOffset,
underlineSvgPath = old.underlineSvgPath,
bgImage = old.bgImage,
bgImageFit = old.bgImageFit,
bgImageScale = old.bgImageScale,
)
).copy(position = index)
}
dao.insertAll(migrated)
// 清除旧 SharedPreferences 数据
appCtx.putPrefString(PreferKey.highlightRuleItems, null)
return
}
}
// 尝试从旧版 RegexColorRule 迁移
migrateFromRegexColorRules()
}
private fun migrateFromRegexColorRules() {
val oldRules = ReadBookConfig.regexColorRules
if (oldRules.isEmpty()) return
val migrated = oldRules.mapIndexed { index, old ->
HighlightRule(
name = old.name,
pattern = old.pattern,
position = index,
textColor = old.color,
)
}
dao.insertAll(migrated)
oldRules.clear()
ReadBookConfig.save()
}
fun sanitizeRule(rule: HighlightRule): HighlightRule {
val name = runCatching { rule.name }.getOrNull().orEmpty()
val pattern = runCatching { rule.pattern }.getOrNull().orEmpty()
val sampleText = runCatching { rule.sampleText }.getOrNull().orEmpty()
val id = runCatching { rule.id }.getOrNull().orEmpty().ifBlank {
"${System.currentTimeMillis()}_${listOf(name, pattern).joinToString("|").hashCode().toUInt().toString(16)}"
}
return HighlightRule(
id = id,
name = name,
pattern = pattern,
sampleText = sampleText,
targetScope = normalizeTargetScope(runCatching { rule.targetScope }.getOrDefault(HighlightRule.TARGET_ALL)),
enabled = runCatching { rule.enabled }.getOrDefault(true),
position = runCatching { rule.position }.getOrDefault(0),
textColor = runCatching { rule.textColor }.getOrNull(),
bgColor = runCatching { rule.bgColor }.getOrNull(),
underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 5),
underlineColor = runCatching { rule.underlineColor }.getOrNull(),
underlineWidth = runCatching { rule.underlineWidth }.getOrDefault(1f).coerceIn(0.1f, 10f),
underlineOffset = runCatching { rule.underlineOffset }.getOrDefault(2f).coerceIn(0f, 20f),
underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(),
bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() },
bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2),
bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f),
)
}
private fun normalizeTargetScope(value: Int, fallback: Int = HighlightRule.TARGET_ALL): Int {
return when (value) {
HighlightRule.TARGET_ALL,
HighlightRule.TARGET_TITLE,
HighlightRule.TARGET_BODY -> value
else -> fallback
}
}
fun createDefaultRules(): List<HighlightRule> {
val ctx = appCtx
return listOf(
HighlightRule(
id = "dialog_default",
name = "对话高亮",
pattern = "“[^\\u201d\\n]{1,120}\\u201d|\"[^\"\\n]{1,120}\"|「[^」\\n]{1,120}」|『[^』\\n]{1,120}』",
sampleText = "她轻声说:“今晚就出发。”",
position = 0,
enabled = ctx.getPrefBoolean(PreferKey.highlightRuleDialog, true),
textColor = 0xFFFF8C00.toInt()
),
HighlightRule(
id = "book_title_default",
name = "书名号高亮",
pattern = "《[^》\\n]{1,80}》",
sampleText = "最近在重读《百年孤独》,节奏依然很稳。",
position = 1,
enabled = ctx.getPrefBoolean(PreferKey.highlightRuleBookTitle, true),
underlineMode = 3,
underlineWidth = 0.5f,
underlineColor = 0xFF63C37D.toInt()
),
HighlightRule(
id = "bracket_note_default",
name = "括号标注高亮",
pattern = "[^()\\n]{1,80}|\\([^()\\n]{1,80}\\)|【[^】\\n]{1,80}】|\\[[^\\]\\n]{1,80}]",
sampleText = "他停了一下(像是忽然想起了什么)。",
position = 2,
enabled = ctx.getPrefBoolean(PreferKey.highlightRuleBracketNote, true),
textColor = 0xFF8F959E.toInt(),
underlineMode = 2,
underlineWidth = 0.5f,
underlineColor = 0xFF5A8DEE.toInt()
),
HighlightRule(
id = "title_emphasis_default",
name = "标题强调",
pattern = "(?m)^\\s{0,2}(?:第[0-9零〇一二两三四五六七八九十百千万IVXLCDMivxlcdm]{1,12}[章节卷回部篇集幕]|序章|楔子|引子|终章|尾声|后记|番外)[^\\n]{0,40}$",
sampleText = "第一章 雨夜来客",
targetScope = HighlightRule.TARGET_TITLE,
position = 3,
enabled = true,
textColor = 0xFF333333.toInt(),
underlineMode = 4,
underlineColor = 0xFF7C5634.toInt()
),
HighlightRule(
id = "thought_default",
name = "心理活动",
pattern = "[^\\n]{0,40}(?:心想|暗道|心道|想到|寻思着|琢磨|嘀咕)[^)\\n]{0,40}",
sampleText = "她心中一紧(暗道不对,这里一定有问题)。",
position = 4,
enabled = false,
textColor = 0xFF9370DB.toInt(),
underlineMode = 1,
underlineWidth = 0.5f,
underlineColor = 0xFF9370DB.toInt()
),
HighlightRule(
id = "narrator_default",
name = "旁白说明",
pattern = "(?:未完待续|待续|下文再表|按:?|注:?)[^\\n]{0,40}|(?:注|旁白|作者有话说)[:][^\\n]{0,40}",
sampleText = "(注:此处时间线与前文同步)",
position = 5,
enabled = false,
textColor = 0xFF708090.toInt()
),
HighlightRule(
id = "emphasis_default",
name = "重点强调",
pattern = "(?:\\*\\*|__)[^\\n*_]{1,40}(?:\\*\\*|__)|(?:!!!|!?|\\?!)[^\\n]{0,20}",
sampleText = "**这是重点内容**,需要特别注意。",
position = 6,
enabled = false,
textColor = 0xFFDC143C.toInt(),
underlineMode = 1,
underlineColor = 0xFFDC143C.toInt()
),
HighlightRule(
id = "poetry_default",
name = "诗词引用",
pattern = "(?m)^[\\p{IsHan},。!?;:、]{5,24}$",
sampleText = "床前明月光,\n疑是地上霜。",
position = 7,
enabled = false,
textColor = 0xFF2F4F4F.toInt(),
underlineMode = 3,
underlineWidth = 0.5f,
underlineColor = 0xFF2F4F4F.toInt()
),
HighlightRule(
id = "ellipsis_default",
name = "省略停顿",
pattern = "…{2,}|\\.{3,}|—{2,}|-{3,}",
sampleText = "他沉默了很久……最后还是点了头。",
position = 8,
enabled = false,
textColor = 0xFF8B8B8B.toInt()
),
HighlightRule(
id = "number_default",
name = "数字金额",
pattern = "(?:¥|¥)?\\d+(?:\\.\\d+)?(?:元|块|万|千|百|亿|%|%)|[零〇一二两三四五六七八九十百千万亿]+(?:元|块|万|千|百|亿)",
sampleText = "原价100元,现在只要50元。",
position = 9,
enabled = false,
textColor = 0xFF4169E1.toInt()
),
HighlightRule(
id = "english_default",
name = "英文单词",
pattern = "\\b[A-Za-z]{2,}[A-Za-z0-9'-]*\\b",
sampleText = "Hello World,你好世界。",
position = 10,
enabled = false,
textColor = 0xFF4169E1.toInt()
),
HighlightRule(
id = "date_time_default",
name = "时间日期",
pattern = "(?:\\d{2,4}|[零〇一二两三四五六七八九十]{2,4})年(?:\\d{1,2}|[正一二三四五六七八九十冬腊])月(?:\\d{1,2}|[一二三四五六七八九十廿三])?[日号]?|\\b\\d{1,2}:\\d{2}\\b|(?:[0-1]?\\d|2[0-3])点(?:[0-5]?\\d分?)?",
sampleText = "2024年8月12日,上午10:30出发。",
position = 11,
enabled = false,
textColor = 0xFF20B2AA.toInt()
)
)
}
private fun cleanupUnusedBgImages(rules: List<HighlightRule>) {
val usedPaths = rules.mapNotNull { it.bgImage }
.filter { it.isNotBlank() && !it.startsWith("assets://") }
.toSet()
val dir = File(appCtx.filesDir, "bg_images")
if (!dir.exists()) return
dir.listFiles()?.forEach { file ->
if (file.absolutePath !in usedPaths) {
runCatching { file.delete() }
}
}
}
private fun restoreRuleBgImage(backupRootPath: String?, bgImage: String?): String? {
val path = bgImage ?: return null
if (path.isBlank() || path.startsWith("assets://")) return path
val rootPath = backupRootPath ?: return path
val backupFile = File(rootPath, "highlightRuleBg${File.separator}${File(path).name}")
.takeIf { it.exists() && it.isFile }
?: return path
val dir = File(appCtx.filesDir, "bg_images")
if (!dir.exists()) dir.mkdirs()
val targetFile = File(dir, backupFile.name)
if (!targetFile.exists() || targetFile.length() != backupFile.length()) {
backupFile.copyTo(targetFile, overwrite = true)
}
return targetFile.absolutePath
}
/**
* 旧版 SharedPreferences 数据结构用于迁移
*/
private data class LegacyHighlightRule(
val id: String = "",
val name: String = "",
val pattern: String = "",
val sampleText: String = "",
val targetScope: Int = 0,
val enabled: Boolean = true,
val textColor: Int? = null,
val underlineMode: Int = 0,
val underlineColor: Int? = null,
val underlineWidth: Float = 1f,
val underlineOffset: Float = 2f,
val underlineSvgPath: String? = null,
val bgImage: String? = null,
val bgImageFit: Int = 0,
val bgImageScale: Float = 1f,
)
}
@@ -0,0 +1,23 @@
package io.legado.app.ui.book.read.config
import android.text.TextPaint
import android.text.style.CharacterStyle
import android.text.style.UpdateAppearance
/**
* 用于在阅读排版阶段传递局部下划线样式
*/
class HighlightStyleSpan(
val underlineMode: Int,
val underlineColor: Int,
val underlineWidth: Float = 1f,
val underlineOffset: Float = 2f,
val underlineSvgPath: String = "",
val bgImage: String = "",
val bgImageFit: Int = 0,
val bgImageScale: Float = 1f,
) : CharacterStyle(), UpdateAppearance {
override fun updateDrawState(tp: TextPaint) = Unit
}
@@ -1,135 +0,0 @@
package io.legado.app.ui.book.read.config
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.data.entities.HttpTTS
import io.legado.app.databinding.DialogHttpTtsEditBinding
import io.legado.app.lib.dialogs.alert
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.code.addJsPattern
import io.legado.app.ui.widget.code.addJsonPattern
import io.legado.app.ui.widget.code.addLegadoPattern
import io.legado.app.utils.GSON
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.showHelp
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
class HttpTtsEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_http_tts_edit),
Toolbar.OnMenuItemClickListener {
constructor(id: Long) : this() {
arguments = Bundle().apply {
putLong("id", id)
}
}
private val binding by viewBinding(DialogHttpTtsEditBinding::bind)
private val viewModel by viewModels<HttpTtsEditViewModel>()
override fun onStart() {
super.onStart()
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
//binding.toolBar.setBackgroundColor(primaryColor)
binding.tvUrl.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
binding.tvLoginUrl.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
binding.tvLoginUi.addJsonPattern()
binding.tvLoginCheckJs.addJsPattern()
binding.tvHeaders.run {
addLegadoPattern()
addJsonPattern()
addJsPattern()
}
viewModel.initData(arguments) {
initView(httpTTS = it)
}
initMenu()
}
fun initMenu() {
binding.toolBar.inflateMenu(R.menu.speak_engine_edit)
//binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
}
fun initView(httpTTS: HttpTTS) {
binding.tvName.setText(httpTTS.name)
binding.tvUrl.setText(httpTTS.url)
binding.tvContentType.setText(httpTTS.contentType)
binding.tvConcurrentRate.setText(httpTTS.concurrentRate)
binding.tvLoginUrl.setText(httpTTS.loginUrl)
binding.tvLoginUi.setText(httpTTS.loginUi)
binding.tvLoginCheckJs.setText(httpTTS.loginCheckJs)
binding.tvHeaders.setText(httpTTS.header)
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_save -> viewModel.save(dataFromView()) {
toastOnUi("保存成功")
}
R.id.menu_login -> dataFromView().let { httpTts ->
if (httpTts.loginUrl.isNullOrBlank()) {
toastOnUi("登录url不能为空")
} else {
viewModel.save(httpTts) {
startActivity<SourceLoginActivity> {
putExtra("type", "httpTts")
putExtra("key", httpTts.id.toString())
}
}
}
}
R.id.menu_show_login_header -> alert {
setTitle(R.string.login_header)
dataFromView().getLoginHeader()?.let { loginHeader ->
setMessage(loginHeader)
}
}
R.id.menu_del_login_header -> dataFromView().removeLoginHeader()
R.id.menu_copy_source -> dataFromView().let {
context?.sendToClip(GSON.toJson(it))
}
R.id.menu_paste_source -> viewModel.importFromClip {
initView(it)
}
R.id.menu_log -> showDialogFragment<AppLogDialog>()
R.id.menu_help -> showHelp("httpTTSHelp")
}
return true
}
private fun dataFromView(): HttpTTS {
return HttpTTS(
id = viewModel.id ?: System.currentTimeMillis(),
name = binding.tvName.text.toString(),
url = binding.tvUrl.text.toString(),
contentType = binding.tvContentType.text?.toString(),
concurrentRate = binding.tvConcurrentRate.text?.toString(),
loginUrl = binding.tvLoginUrl.text?.toString(),
loginUi = binding.tvLoginUi.text?.toString(),
loginCheckJs = binding.tvLoginCheckJs.text?.toString(),
header = binding.tvHeaders.text?.toString()
)
}
}
@@ -1,76 +0,0 @@
package io.legado.app.ui.book.read.config
import android.app.Application
import android.os.Bundle
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.HttpTTS
import io.legado.app.exception.NoStackTraceException
import io.legado.app.model.ReadAloud
import io.legado.app.utils.getClipText
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.toastOnUi
class HttpTtsEditViewModel(app: Application) : BaseViewModel(app) {
var id: Long? = null
fun initData(arguments: Bundle?, success: (httpTTS: HttpTTS) -> Unit) {
execute {
if (id == null) {
val argumentId = arguments?.getLong("id")
if (argumentId != null && argumentId != 0L) {
id = argumentId
return@execute appDb.httpTTSDao.get(argumentId)
}
}
return@execute null
}.onSuccess {
it?.let {
success.invoke(it)
}
}
}
fun save(httpTTS: HttpTTS, success: (() -> Unit)? = null) {
id = httpTTS.id
execute {
appDb.httpTTSDao.insert(httpTTS)
if (ReadAloud.ttsEngine == httpTTS.id.toString()) ReadAloud.upReadAloudClass()
}.onSuccess {
success?.invoke()
}
}
fun importFromClip(onSuccess: (httpTTS: HttpTTS) -> Unit) {
val text = context.getClipText()
if (text.isNullOrBlank()) {
context.toastOnUi("剪贴板为空")
} else {
importSource(text, onSuccess)
}
}
fun importSource(text: String, onSuccess: (httpTTS: HttpTTS) -> Unit) {
val text1 = text.trim()
execute {
when {
text1.isJsonObject() -> {
HttpTTS.fromJson(text1).getOrThrow()
}
text1.isJsonArray() -> {
HttpTTS.fromJsonArray(text1).getOrThrow().first()
}
else -> {
throw NoStackTraceException("格式不对")
}
}
}.onSuccess {
onSuccess.invoke(it)
}.onError {
context.toastOnUi(it.localizedMessage)
}
}
}
@@ -1,284 +0,0 @@
package io.legado.app.ui.book.read.config
import android.os.Bundle
import android.view.View
import com.jaredrummler.android.colorpicker.ColorPickerDialog
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogReadInfoBinding
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadTipConfig
import io.legado.app.lib.dialogs.selector
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR
import io.legado.app.utils.getCompatColor
import io.legado.app.utils.observeEvent
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info), FontSelectDialog.CallBack {
private val binding by viewBinding(DialogReadInfoBinding::bind)
private val callBack get() = activity as? ReadBookActivity
override val curFontPath: String
get() = ReadBookConfig.headerFont
override fun selectFont(path: String) {
ReadBookConfig.headerFont = path
ReadBookConfig.save()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initEvent()
observeEvent<String>(EventBus.TIP_COLOR) {
upTvHeaderColor()
upTvFooterColor()
upTvTipDividerColor()
}
observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) { list ->
if (list.contains(2)) {
upBtnHeaderMode()
upBtnFooterMode()
}
}
}
private fun initView() {
ReadTipConfig.run {
tipNames.let { tipNames ->
binding.tvHeaderLeft.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderLeft)) { tipNames[none] }
binding.tvHeaderMiddle.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderMiddle)) { tipNames[none] }
binding.tvHeaderRight.text =
tipNames.getOrElse(tipValues.indexOf(tipHeaderRight)) { tipNames[none] }
binding.tvFooterLeft.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterLeft)) { tipNames[none] }
binding.tvFooterMiddle.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterMiddle)) { tipNames[none] }
binding.tvFooterRight.text =
tipNames.getOrElse(tipValues.indexOf(tipFooterRight)) { tipNames[none] }
}
}
binding.btnPaddingSetting.setOnClickListener {
callBack?.showPaddingConfig()
dismissAllowingStateLoss()
}
upTvHeaderColor()
upTvFooterColor()
upTvTipDividerColor()
binding.scvHeaderFontSize.progress = ReadBookConfig.headerFontSize
}
private fun upTvHeaderColor() {
val tipColor = if (ReadTipConfig.tipHeaderColor == 0) {
ReadBookConfig.textColor
} else {
ReadTipConfig.tipHeaderColor
}
binding.btnHeaderColor.color = tipColor
}
private fun upTvFooterColor() {
val tipColor = if (ReadTipConfig.tipFooterColor == 0) {
ReadBookConfig.textColor
} else {
ReadTipConfig.tipFooterColor
}
binding.btnFooterColor.color = tipColor
}
private fun upTvTipDividerColor() {
val tipDividerColor = when (ReadTipConfig.tipDividerColor) {
-1 -> getCompatColor(R.color.divider)
0 -> ReadBookConfig.textColor
else -> ReadTipConfig.tipDividerColor
}
binding.btnDividerColor.color = tipDividerColor
}
private fun upBtnHeaderMode() {
val headerModes = ReadTipConfig.getHeaderModes(requireContext())
binding.btnHeaderMode.text = headerModes[ReadTipConfig.headerMode] ?: getString(R.string.header)
}
private fun upBtnFooterMode() {
val footerModes = ReadTipConfig.getFooterModes(requireContext())
binding.btnFooterMode.text = footerModes[ReadTipConfig.footerMode] ?: getString(R.string.footer)
}
private fun initEvent() = binding.run {
val headerModes = ReadTipConfig.getHeaderModes(requireContext())
binding.btnHeaderMode.text = headerModes[ReadTipConfig.headerMode] ?: getString(R.string.header)
binding.btnHeaderMode.setOnClickListener {
val items = headerModes.values.toList()
context?.selector(items = items) { _, index ->
val selectedKey = headerModes.keys.toList()[index]
ReadTipConfig.headerMode = selectedKey
binding.btnHeaderMode.text = headerModes[selectedKey]
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
val footerModes = ReadTipConfig.getFooterModes(requireContext())
binding.btnFooterMode.text = footerModes[ReadTipConfig.footerMode] ?: getString(R.string.footer)
binding.btnFooterMode.setOnClickListener {
val items = footerModes.values.toList()
context?.selector(items = items) { _, index ->
val selectedKey = footerModes.keys.toList()[index]
ReadTipConfig.footerMode = selectedKey
binding.btnFooterMode.text = footerModes[selectedKey]
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
llHeaderLeft.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderLeft = tipValue
tvHeaderLeft.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llHeaderMiddle.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderMiddle = tipValue
tvHeaderMiddle.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llHeaderRight.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipHeaderRight = tipValue
tvHeaderRight.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterLeft.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterLeft = tipValue
tvFooterLeft.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterMiddle.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterMiddle = tipValue
tvFooterMiddle.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
llFooterRight.setOnClickListener {
context?.selector(items = ReadTipConfig.tipNames) { _, i ->
val tipValue = ReadTipConfig.tipValues[i]
clearRepeat(tipValue)
ReadTipConfig.tipFooterRight = tipValue
tvFooterRight.text = ReadTipConfig.tipNames[i]
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
}
}
btnHeaderColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
when (i) {
0 -> {
ReadTipConfig.tipHeaderColor = 0
upTvHeaderColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
1 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TipConfigDialog.TIP_HEADER_COLOR)
.show(requireActivity())
}
}
}
btnFooterColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
when (i) {
0 -> {
ReadTipConfig.tipFooterColor = 0
upTvFooterColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
1 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TipConfigDialog.TIP_FOOTER_COLOR)
.show(requireActivity())
}
}
}
btnDividerColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipDividerColorNames) { _, i ->
when (i) {
0, 1 -> {
ReadTipConfig.tipDividerColor = i - 1
upTvTipDividerColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
2 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(TIP_DIVIDER_COLOR)
.show(requireActivity())
}
}
}
btnHeaderFont.setOnClickListener {
FontSelectDialog().show(childFragmentManager, "headerFontSelect")
}
binding.scvHeaderFontSize.onChanged = {
ReadBookConfig.headerFontSize = it
ReadBookConfig.save()
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
private fun clearRepeat(repeat: Int) = ReadTipConfig.apply {
if (repeat != none) {
if (tipHeaderLeft == repeat) {
tipHeaderLeft = none
binding.tvHeaderLeft.text = tipNames[none]
}
if (tipHeaderMiddle == repeat) {
tipHeaderMiddle = none
binding.tvHeaderMiddle.text = tipNames[none]
}
if (tipHeaderRight == repeat) {
tipHeaderRight = none
binding.tvHeaderRight.text = tipNames[none]
}
if (tipFooterLeft == repeat) {
tipFooterLeft = none
binding.tvFooterLeft.text = tipNames[none]
}
if (tipFooterMiddle == repeat) {
tipFooterMiddle = none
binding.tvFooterMiddle.text = tipNames[none]
}
if (tipFooterRight == repeat) {
tipFooterRight = none
binding.tvFooterRight.text = tipNames[none]
}
}
}
}
@@ -1,188 +0,0 @@
package io.legado.app.ui.book.read.config
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.primaryColor
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import android.view.ViewConfiguration
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.page.provider.ChapterProvider
import io.legado.app.ui.widget.number.NumberPickerDialog
import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.postEvent
import io.legado.app.utils.removePref
class MoreConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_more_config) {
private val readPreferTag = "readPreferenceFragment"
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as? ReadBookActivity)?.bottomDialog++
var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag)
if (preferenceFragment == null) preferenceFragment = ReadPreferenceFragment()
childFragmentManager.beginTransaction()
.replace(R.id.containerPreferences, preferenceFragment, readPreferTag)
.commit()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
class ReadPreferenceFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
private val slopSquare by lazy { ViewConfiguration.get(requireContext()).scaledTouchSlop }
@SuppressLint("RestrictedApi")
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_config_read)
upPreferenceSummary(PreferKey.menuAlpha, AppConfig.menuAlpha.toString())
upPreferenceSummary(PreferKey.pageTouchSlop, slopSquare.toString())
if (!CanvasRecorderFactory.isSupport) {
removePref(PreferKey.optimizeRender)
preferenceScreen.removePreferenceRecursively(PreferKey.optimizeRender)
}
}
override fun onResume() {
super.onResume()
preferenceManager
.sharedPreferences
?.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager
.sharedPreferences
?.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?
) {
when (key) {
PreferKey.readBodyToLh -> activity?.recreate()
PreferKey.hideStatusBar -> {
ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar)
postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
}
PreferKey.hideNavigationBar -> {
ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar)
postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2))
}
PreferKey.keepLight -> postEvent(key, true)
PreferKey.readSliderMode, PreferKey.titleBarMode -> postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key))
PreferKey.screenOrientation -> {
(activity as? ReadBookActivity)?.setOrientation()
}
PreferKey.textFullJustify,
PreferKey.textBottomJustify,
PreferKey.useZhLayout, PreferKey.adaptSpecialStyle, PreferKey.useUnderline -> {
postEvent(EventBus.UP_CONFIG, arrayListOf(5))
}
PreferKey.showBrightnessView -> {
postEvent(PreferKey.showBrightnessView, "")
}
PreferKey.expandTextMenu -> {
(activity as? ReadBookActivity)?.textActionMenu?.upMenu()
}
PreferKey.doublePageHorizontal -> {
ChapterProvider.upLayout()
ReadBook.loadContent(false)
}
PreferKey.showReadTitleAddition,
PreferKey.readBarStyleFollowPage -> {
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
}
PreferKey.progressBarBehavior -> {
postEvent(EventBus.UP_SEEK_BAR, true)
}
PreferKey.noAnimScrollPage -> {
ReadBook.callBack?.upPageAnim()
}
PreferKey.optimizeRender -> {
ChapterProvider.upStyle()
ReadBook.callBack?.upPageAnim(true)
ReadBook.loadContent(false)
}
PreferKey.paddingDisplayCutouts -> {
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
"customPageKey" -> PageKeyDialog(requireContext()).show()
"clickRegionalConfig" -> {
(activity as? ReadBookActivity)?.showClickRegionalConfig()
}
PreferKey.menuAlpha -> {
NumberPickerDialog(requireContext())
.setTitle(getString(R.string.menu_alpha))
.setMaxValue(100)
.setMinValue(0)
.setValue(AppConfig.menuAlpha)
.show {
AppConfig.menuAlpha = it
upPreferenceSummary(PreferKey.menuAlpha, it.toString())
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
}
}
PreferKey.pageTouchSlop -> {
NumberPickerDialog(requireContext())
.setTitle(getString(R.string.page_touch_slop_dialog_title))
.setMaxValue(9999)
.setMinValue(0)
.setValue(AppConfig.pageTouchSlop)
.show {
AppConfig.pageTouchSlop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(4))
}
}
}
return super.onPreferenceTreeClick(preference)
}
@Suppress("SameParameterValue")
private fun upPreferenceSummary(preferenceKey: String, value: String?) {
val preference = findPreference<Preference>(preferenceKey) ?: return
when (preferenceKey) {
PreferKey.menuAlpha -> preference.summary =
getString(R.string.menu_alpha_sum, AppConfig.menuAlpha)
PreferKey.pageTouchSlop -> preference.summary =
getString(R.string.page_touch_slop_summary, value)
}
}
}
}
@@ -1,119 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import io.legado.app.R
import io.legado.app.base.BaseDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogReadPaddingBinding
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.utils.postEvent
import io.legado.app.utils.setLayout
import io.legado.app.utils.viewbindingdelegate.viewBinding
class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) {
private val binding by viewBinding(DialogReadPaddingBinding::bind)
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initData()
initView()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
}
override fun onStart() {
super.onStart()
if (resources.configuration.smallestScreenWidthDp > 600)
setLayout(0.6f, 0.6f)
else
setLayout(0.9f, 0.8f)
}
private fun initData() = binding.run {
//正文
dsbPaddingTop.progress = ReadBookConfig.paddingTop
dsbPaddingBottom.progress = ReadBookConfig.paddingBottom
dsbPaddingLeft.progress = ReadBookConfig.paddingLeft
dsbPaddingRight.progress = ReadBookConfig.paddingRight
//页眉
dsbHeaderPaddingTop.progress = ReadBookConfig.headerPaddingTop
dsbHeaderPaddingBottom.progress = ReadBookConfig.headerPaddingBottom
dsbHeaderPaddingLeft.progress = ReadBookConfig.headerPaddingLeft
dsbHeaderPaddingRight.progress = ReadBookConfig.headerPaddingRight
//页脚
dsbFooterPaddingTop.progress = ReadBookConfig.footerPaddingTop
dsbFooterPaddingBottom.progress = ReadBookConfig.footerPaddingBottom
dsbFooterPaddingLeft.progress = ReadBookConfig.footerPaddingLeft
dsbFooterPaddingRight.progress = ReadBookConfig.footerPaddingRight
cbShowTopLine.isChecked = ReadBookConfig.showHeaderLine
cbShowBottomLine.isChecked = ReadBookConfig.showFooterLine
}
private fun initView() = binding.run {
//正文
dsbPaddingTop.onChanged = {
ReadBookConfig.paddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingBottom.onChanged = {
ReadBookConfig.paddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingLeft.onChanged = {
ReadBookConfig.paddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
dsbPaddingRight.onChanged = {
ReadBookConfig.paddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
}
//页眉
dsbHeaderPaddingTop.onChanged = {
ReadBookConfig.headerPaddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingBottom.onChanged = {
ReadBookConfig.headerPaddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingLeft.onChanged = {
ReadBookConfig.headerPaddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbHeaderPaddingRight.onChanged = {
ReadBookConfig.headerPaddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
//页脚
dsbFooterPaddingTop.onChanged = {
ReadBookConfig.footerPaddingTop = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingBottom.onChanged = {
ReadBookConfig.footerPaddingBottom = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingLeft.onChanged = {
ReadBookConfig.footerPaddingLeft = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
dsbFooterPaddingRight.onChanged = {
ReadBookConfig.footerPaddingRight = it
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
cbShowTopLine.setOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.showHeaderLine = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
cbShowBottomLine.setOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.showFooterLine = isChecked
postEvent(EventBus.UP_CONFIG, arrayListOf(2))
}
}
}
@@ -1,72 +0,0 @@
package io.legado.app.ui.book.read.config
import android.app.Dialog
import android.content.Context
import android.view.KeyEvent
import android.view.ViewGroup
import io.legado.app.constant.PreferKey
import io.legado.app.databinding.DialogPageKeyBinding
//import io.legado.app.lib.theme.backgroundColor
import io.legado.app.utils.getPrefString
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.putPrefString
import io.legado.app.utils.setLayout
import splitties.views.onClick
class PageKeyDialog(context: Context) : Dialog(context) {
private val binding = DialogPageKeyBinding.inflate(layoutInflater)
override fun onStart() {
super.onStart()
setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT)
}
init {
setContentView(binding.root)
binding.run {
//contentView.setBackgroundColor(context.backgroundColor)
etPrev.setText(context.getPrefString(PreferKey.prevKeys))
etNext.setText(context.getPrefString(PreferKey.nextKeys))
tvReset.onClick {
etPrev.setText("")
etNext.setText("")
}
tvOk.setOnClickListener {
context.putPrefString(PreferKey.prevKeys, etPrev.text?.toString())
context.putPrefString(PreferKey.nextKeys, etNext.text?.toString())
dismiss()
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) {
if (binding.etPrev.hasFocus()) {
val editableText = binding.etPrev.editableText
if (editableText.isEmpty() or editableText.endsWith(",")) {
editableText.append(keyCode.toString())
} else {
editableText.append(",").append(keyCode.toString())
}
return true
} else if (binding.etNext.hasFocus()) {
val editableText = binding.etNext.editableText
if (editableText.isEmpty() or editableText.endsWith(",")) {
editableText.append(keyCode.toString())
} else {
editableText.append(",").append(keyCode.toString())
}
return true
}
}
return super.onKeyDown(keyCode, event)
}
override fun dismiss() {
super.dismiss()
currentFocus?.hideSoftInput()
}
}
@@ -1,211 +0,0 @@
package io.legado.app.ui.book.read.config
//import io.legado.app.lib.theme.backgroundColor
//import io.legado.app.lib.theme.primaryColor
// 【新增引用】为了显示清理成功的提示
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.preference.ListPreference
import androidx.preference.Preference
import io.legado.app.R
import io.legado.app.base.BasePrefDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.help.IntentHelp
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.prefs.SwitchPreference
import io.legado.app.lib.prefs.fragment.PreferenceFragment
import io.legado.app.model.ReadAloud
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.widget.number.NumberPickerDialog
import io.legado.app.utils.GSON
import io.legado.app.utils.StringUtils
import io.legado.app.utils.TTSCacheUtils
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.postEvent
import io.legado.app.utils.putPrefInt
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
class ReadAloudConfigDialog : BasePrefDialogFragment() {
private val readAloudPreferTag = "readAloudPreferTag"
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = LinearLayout(requireContext())
//view.setBackgroundColor(requireContext().backgroundColor)
view.id = R.id.tag1
container?.addView(view)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag)
if (preferenceFragment == null) preferenceFragment = ReadAloudPreferenceFragment()
childFragmentManager.beginTransaction()
.replace(view.id, preferenceFragment, readAloudPreferTag)
.commit()
}
class ReadAloudPreferenceFragment : PreferenceFragment(),
SpeakEngineDialog.CallBack,
SharedPreferences.OnSharedPreferenceChangeListener {
private val speakEngineSummary: String
get() {
val ttsEngine = ReadAloud.ttsEngine
?: return getString(R.string.system_tts)
if (StringUtils.isNumeric(ttsEngine)) {
return appDb.httpTTSDao.getName(ttsEngine.toLong())
?: getString(R.string.system_tts)
}
return GSON.fromJsonObject<SelectItem<String>>(ttsEngine).getOrNull()?.title
?: getString(R.string.system_tts)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_config_aloud)
upSpeakEngineSummary()
upPreferenceSummary(PreferKey.audioPreDownloadNum)
upPreferenceSummary(PreferKey.audioCacheCleanTime)
findPreference<SwitchPreference>(PreferKey.pauseReadAloudWhilePhoneCalls)?.let {
it.isEnabled = AppConfig.ignoreAudioFocus
}
findPreference<Preference>("clear_cache")?.let {
it.summary = getString(R.string.clear_cache)
it.setOnPreferenceClickListener {
TTSCacheUtils.clearTtsCache()
toastOnUi("音频缓存已清理")
true
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//listView.setEdgeEffectColor(primaryColor)
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
when (preference.key) {
PreferKey.audioPreDownloadNum -> {
NumberPickerDialog(requireContext())
.setTitle(getString(R.string.read_aloud_preload))
.setMaxValue(50)
.setMinValue(0)
.setValue(10)
.setCustomButton((R.string.btn_default_s)) {
putPrefInt(PreferKey.audioPreDownloadNum, 10)
upPreferenceSummary(PreferKey.audioPreDownloadNum)
}
.show {
putPrefInt(PreferKey.audioPreDownloadNum, it)
upPreferenceSummary(PreferKey.audioPreDownloadNum)
}
}
PreferKey.audioCacheCleanTime -> {
NumberPickerDialog(requireContext())
.setTitle(getString(R.string.audio_cache_clean_time))
.setMaxValue(50)
.setMinValue(0)
.setValue(1)
.setCustomButton((R.string.btn_default_s)) {
putPrefInt(PreferKey.audioCacheCleanTime, 10)
upPreferenceSummary(PreferKey.audioCacheCleanTime)
}
.show {
putPrefInt(PreferKey.audioCacheCleanTime, it)
upPreferenceSummary(PreferKey.audioCacheCleanTime)
}
}
PreferKey.ttsEngine -> showDialogFragment(SpeakEngineDialog())
"sysTtsConfig" -> IntentHelp.openTTSSetting()
}
return super.onPreferenceTreeClick(preference)
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences?,
key: String?
) {
when (key) {
PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> {
if (BaseReadAloudService.isRun) {
postEvent(EventBus.MEDIA_BUTTON, false)
}
}
PreferKey.ignoreAudioFocus -> {
findPreference<SwitchPreference>(PreferKey.pauseReadAloudWhilePhoneCalls)?.let {
it.isEnabled = AppConfig.ignoreAudioFocus
}
}
}
}
private fun upPreferenceSummary(preference: Preference?, value: String) {
when (preference) {
is ListPreference -> {
val index = preference.findIndexOfValue(value)
preference.summary = if (index >= 0) preference.entries[index] else null
}
else -> {
preference?.summary = value
}
}
}
private fun upPreferenceSummary(preferenceKey: String, value: String? = null) {
val preference = findPreference<Preference>(preferenceKey) ?: return
when (preferenceKey) {
PreferKey.audioPreDownloadNum -> {
preference.summary = getString(
R.string.read_aloud_preload_summary,
AppConfig.audioPreDownloadNum
)
}
PreferKey.audioCacheCleanTime -> {
preference.summary = getString(
R.string.audio_cache_clean_time_summary,
AppConfig.audioCacheCleanTimeOrgin
)
}
else -> preference.summary = value
}
}
override fun upSpeakEngineSummary() {
upPreferenceSummary(
findPreference(PreferKey.ttsEngine),
speakEngineSummary
)
}
}
}
@@ -1,261 +0,0 @@
package io.legado.app.ui.book.read.config
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import com.google.android.material.slider.Slider
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogReadAloudBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.selector
import io.legado.app.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.service.BaseReadAloudService
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.observeEvent
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
class ReadAloudDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_aloud) {
private val callBack: CallBack? get() = activity as? CallBack
private val binding by viewBinding(DialogReadAloudBinding::bind)
override fun onStart() {
super.onStart()
// dialog?.window?.run {
// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// setBackgroundDrawableResource(R.color.background)
// decorView.setPadding(0, 0, 0, 0)
// val attr = attributes
// attr.dimAmount = 0.0f
// attr.gravity = Gravity.BOTTOM
// attributes = attr
// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// }
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
(activity as ReadBookActivity).bottomDialog--
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
val bottomDialog = (activity as ReadBookActivity).bottomDialog++
if (bottomDialog > 0) {
dismiss()
return
}
//val bg = requireContext().bottomBackground
//val isLight = ColorUtils.isColorLight(bg)
//val textColor = requireContext().getPrimaryTextColor(isLight)
binding.run {
// rootView.setBackgroundColor(bg)
// tvPre.setTextColor(textColor)
// tvNext.setTextColor(textColor)
// ivPlayPrev.setColorFilter(textColor)
// ivPlayPause.setColorFilter(textColor)
// ivPlayNext.setColorFilter(textColor)
// ivStop.setColorFilter(textColor)
// ivTimer.setColorFilter(textColor)
// tvTimer.setTextColor(textColor)
// ivTtsSpeechReduce.setColorFilter(textColor)
// tvTtsSpeed.setTextColor(textColor)
// tvTtsSpeedValue.setTextColor(textColor)
// ivTtsSpeechAdd.setColorFilter(textColor)
// ivCatalog.setColorFilter(textColor)
// tvCatalog.setTextColor(textColor)
// ivMainMenu.setColorFilter(textColor)
// tvMainMenu.setTextColor(textColor)
// ivToBackstage.setColorFilter(textColor)
// tvToBackstage.setTextColor(textColor)
// ivSetting.setColorFilter(textColor)
// tvSetting.setTextColor(textColor)
// cbTtsFollowSys.setTextColor(textColor)
}
initData()
initEvent()
}
private fun initData() = binding.run {
upPlayState()
upTimerText(BaseReadAloudService.timeMinute)
cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true)
upTtsSpeechRateEnabled(!cbTtsFollowSys.isChecked)
upSeekTimer()
}
private fun initEvent() = binding.run {
ivMainMenu.setOnClickListener {
callBack?.showMenuBar()
dismissAllowingStateLoss()
}
ivSetting.setOnClickListener {
ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog")
}
tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) }
tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) }
ivStop.setOnClickListener {
ReadAloud.stop(requireContext())
dismissAllowingStateLoss()
}
ivPlayPause.setOnClickListener { callBack?.onClickReadAloud() }
ivPlayPrev.setOnClickListener { ReadAloud.prevParagraph(requireContext()) }
ivPlayNext.setOnClickListener { ReadAloud.nextParagraph(requireContext()) }
ivCatalog.setOnClickListener { callBack?.openChapterList() }
ivToBackstage.setOnClickListener { callBack?.finish() }
cbTtsFollowSys.setOnCheckedChangeListener { _, isChecked ->
AppConfig.ttsFlowSys = isChecked
upTtsSpeechRateEnabled(!isChecked)
upTtsSpeechRate()
}
ivTimer.setOnClickListener {
AppConfig.ttsTimer = seekTimer.value.toInt()
toastOnUi("保存设定时间成功!")
}
// 设置初始值
seekTtsSpeechRate.value = AppConfig.ttsSpeechRate.toFloat()
seekTimer.value = if (BaseReadAloudService.timeMinute > 0)
BaseReadAloudService.timeMinute.toFloat()
else AppConfig.ttsTimer.toFloat()
// 减速按钮逻辑
ivTtsSpeechReduce.setOnClickListener {
val newValue = (seekTtsSpeechRate.value - 1).coerceAtLeast(seekTtsSpeechRate.valueFrom)
seekTtsSpeechRate.value = newValue
AppConfig.ttsSpeechRate = newValue.toInt()
upTtsSpeechRateText(newValue.toInt())
upTtsSpeechRate()
}
// 加速按钮逻辑
ivTtsSpeechAdd.setOnClickListener {
val newValue = (seekTtsSpeechRate.value + 1).coerceAtMost(seekTtsSpeechRate.valueTo)
seekTtsSpeechRate.value = newValue
AppConfig.ttsSpeechRate = newValue.toInt()
upTtsSpeechRateText(newValue.toInt())
upTtsSpeechRate()
}
btnTimer.setOnClickListener {
val times = intArrayOf(0, 5, 10, 15, 30, 60, 90, 180)
val timeKeys = times.map { "$it 分钟" }
context?.selector("设定时间", timeKeys) { _, index ->
ReadAloud.setTimer(requireContext(), times[index])
upTimerText(times[index])
}
}
//设置保存的默认值
seekTtsSpeechRate.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
upTtsSpeechRateText(value.toInt())
}
}
seekTtsSpeechRate.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
AppConfig.ttsSpeechRate = slider.value.toInt()
upTtsSpeechRate()
}
})
seekTimer.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
upTimerText(value.toInt())
}
}
seekTimer.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}
override fun onStopTrackingTouch(slider: Slider) {
ReadAloud.setTimer(requireContext(), slider.value.toInt())
}
})
}
private fun upTtsSpeechRateEnabled(enabled: Boolean) {
binding.run {
upTtsSpeechRateText(AppConfig.ttsSpeechRate)
tvTtsSpeedValue.visible(enabled)
seekTtsSpeechRate.isEnabled = enabled
ivTtsSpeechReduce.isEnabled = enabled
ivTtsSpeechAdd.isEnabled = enabled
}
}
private fun upPlayState() {
if (!BaseReadAloudService.pause) {
binding.ivPlayPause.icon =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_pause)
binding.ivPlayPause.contentDescription = getString(R.string.pause)
} else {
binding.ivPlayPause.icon =
ContextCompat.getDrawable(requireContext(), R.drawable.ic_play)
binding.ivPlayPause.contentDescription = getString(R.string.audio_play)
}
// val bg = requireContext().bottomBackground
// val isLight = ColorUtils.isColorLight(bg)
// val textColor = requireContext().getPrimaryTextColor(isLight)
// binding.ivPlayPause.iconTint = ColorStateList.valueOf(textColor)
}
private fun upSeekTimer() {
binding.seekTimer.post {
binding.seekTimer.value = if (BaseReadAloudService.timeMinute > 0) {
BaseReadAloudService.timeMinute.toFloat()
} else {
AppConfig.ttsTimer.toFloat()
}
}
}
private fun upTimerText(timeMinute: Int) {
if (timeMinute < 0) {
binding.btnTimer.text = requireContext().getString(R.string.timer_m, 0)
} else {
binding.btnTimer.text = requireContext().getString(R.string.timer_m, timeMinute)
}
}
@SuppressLint("SetTextI18n")
private fun upTtsSpeechRateText(value: Int) {
binding.tvTtsSpeedValue.text = value.toString()
}
private fun upTtsSpeechRate() {
ReadAloud.upTtsSpeechRate(requireContext())
if (!BaseReadAloudService.pause) {
ReadAloud.pause(requireContext())
ReadAloud.resume(requireContext())
}
}
override fun observeLiveBus() {
observeEvent<Int>(EventBus.ALOUD_STATE) { upPlayState() }
observeEvent<Int>(EventBus.READ_ALOUD_DS) {
val value = it.coerceIn(binding.seekTimer.valueFrom.toInt(), binding.seekTimer.valueTo.toInt())
binding.seekTimer.value = value.toFloat()
}
}
interface CallBack {
fun showMenuBar()
fun openChapterList()
fun onClickReadAloud()
fun finish()
}
}
@@ -1,246 +0,0 @@
package io.legado.app.ui.book.read.config
import android.content.DialogInterface
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.core.view.get
import com.github.liuyueyi.quick.transfer.constants.TransType
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogReadBookStyleBinding
import io.legado.app.databinding.ItemReadStyleBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.OldThemeConfig
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.utils.ChineseUtils
import io.legado.app.utils.dpToPx
import io.legado.app.utils.postEvent
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.viewbindingdelegate.viewBinding
class ReadStyleDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_book_style),
FontConfigDialog.CallBack {
private val binding by viewBinding(DialogReadBookStyleBinding::bind)
private val callBack get() = activity as? ReadBookActivity
private lateinit var styleAdapter: StyleAdapter
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
(activity as ReadBookActivity).bottomDialog++
initView()
initData()
initViewEvent()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
ReadBookConfig.save()
(activity as ReadBookActivity).bottomDialog--
}
private fun initView() = binding.run {
if (AppConfig.isNightTheme) {
tvDayNight.setIconResource(R.drawable.ic_daytime)
} else {
tvDayNight.setIconResource(R.drawable.ic_brightness)
}
dsbTextSize.valueFormat = {
(it + 5).toString()
}
styleAdapter = StyleAdapter()
rvStyle.adapter = styleAdapter
styleAdapter.addFooterView {
ItemReadStyleBinding.inflate(layoutInflater, it, false).apply {
tvStyle.text = ""
cdStyle.cardElevation = 0f
cdStyle.radius = 8f.dpToPx()
cdStyle.strokeWidth = 1.dpToPx()
ivStyle.setImageResource(R.drawable.ic_add)
ivStyle.setPadding(12.dpToPx(),12.dpToPx(),12.dpToPx(),12.dpToPx())
root.setOnClickListener {
ReadBookConfig.configList.add(ReadBookConfig.Config())
showBgTextConfig(ReadBookConfig.configList.lastIndex)
}
}
}
}
private fun initData() {
binding.cbShareLayout.isChecked = ReadBookConfig.shareLayout
upView()
styleAdapter.setItems(ReadBookConfig.configList)
}
private fun updateChineseIcon() {
val text = when (AppConfig.chineseConverterType) {
1 -> ""
2 -> ""
else -> null
}
binding.btnChineseConverter.text = text
}
private fun initViewEvent() = binding.run {
updateChineseIcon()
btnChineseConverter.setOnClickListener {
alert(titleResource = R.string.chinese_converter) {
items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i ->
AppConfig.chineseConverterType = i
ChineseUtils.unLoad(*TransType.entries.toTypedArray())
postEvent(EventBus.UP_CONFIG, arrayListOf(5))
updateChineseIcon()
}
}
}
tvTextFont.setOnClickListener {
callBack?.showFont()
}
tvPadding.setOnClickListener {
callBack?.showInfoConfig()
}
tvTip.setOnClickListener {
TipConfigDialog().show(childFragmentManager, "tipConfigDialog")
}
tvMore.setOnClickListener {
showDialogFragment<MoreConfigDialog>()
}
tvDayNight.setOnClickListener {
AppConfig.isNightTheme = !AppConfig.isNightTheme
OldThemeConfig.applyDayNight(requireContext())
}
// rgPageAnim.setOnCheckedChangeListener { _, checkedId ->
// ReadBook.book?.setPageAnim(-1)
// ReadBookConfig.pageAnim = binding.rgPageAnim.getIndexById(checkedId)
// callBack?.upPageAnim()
// ReadBook.loadContent(false)
// }
binding.rgPageAnim.setOnCheckedStateChangeListener { group, checkedIds ->
val checkedId = checkedIds.firstOrNull() ?: return@setOnCheckedStateChangeListener
ReadBook.book?.setPageAnim(-1)
ReadBookConfig.pageAnim = when (checkedId) {
R.id.rb_anim0 -> 0 // 覆盖动画
R.id.rb_anim1 -> 1 // 滑动动画
R.id.rb_simulation_anim -> 2 // 仿真翻页
R.id.rb_scroll_anim -> 3 // 滚动动画
R.id.rb_fade_anim -> 4
R.id.rb_no_anim -> 5 // 无动画
else -> 0
}
callBack?.upPageAnim()
ReadBook.loadContent(false)
}
cbShareLayout.addOnCheckedChangeListener { _, isChecked ->
ReadBookConfig.shareLayout = isChecked
upView()
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
}
dsbTextSize.onChanged = {
ReadBookConfig.textSize = it + 5
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
private fun changeBgTextConfig(index: Int) {
val oldIndex = ReadBookConfig.styleSelect
if (index != oldIndex) {
ReadBookConfig.styleSelect = index
upView()
styleAdapter.notifyItemChanged(oldIndex)
styleAdapter.notifyItemChanged(index)
postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5))
if (AppConfig.readBarStyleFollowPage) {
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
}
}
}
private fun showBgTextConfig(index: Int): Boolean {
changeBgTextConfig(index)
callBack?.showBgTextConfig()
return true
}
private fun upView() = binding.run {
ReadBook.pageAnim().let {
if (it >= 0 && it < rgPageAnim.childCount) {
rgPageAnim.check(rgPageAnim[it].id)
}
}
ReadBookConfig.let {
dsbTextSize.progress = it.textSize - 5
}
}
override val curFontPath: String
get() = ReadBookConfig.textFont
override fun selectFont(path: String) {
if (path != ReadBookConfig.textFont || path.isEmpty()) {
ReadBookConfig.textFont = path
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 5))
}
}
inner class StyleAdapter :
RecyclerAdapter<ReadBookConfig.Config, ItemReadStyleBinding>(requireContext()) {
override fun getViewBinding(parent: ViewGroup): ItemReadStyleBinding {
return ItemReadStyleBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemReadStyleBinding,
item: ReadBookConfig.Config,
payloads: MutableList<Any>
) {
binding.apply {
tvStyle.text = item.name.ifBlank { "文字" }
tvStyle.setTextColor(item.curTextColor())
ivStyle.setImageDrawable(item.curBgDrawable(100, 150))
cdStyle.strokeWidth = 1.dpToPx()
if (ReadBookConfig.styleSelect == holder.layoutPosition) {
llStyle.gravity = Gravity.TOP
cdStyle.radius = 32f.dpToPx()
//cdStyle.strokeColor = item.curTextColor()
//tvStyle.setTextBold(true)
} else {
cdStyle.radius = 8f.dpToPx()
//cdStyle.strokeColor = item.curTextColor()
//tvStyle.setTextBold(false)
}
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemReadStyleBinding) {
binding.apply {
cdStyle.setOnClickListener {
changeBgTextConfig(holder.layoutPosition)
}
cdStyle.setOnLongClickListener {
dismissAllowingStateLoss()
showBgTextConfig(holder.layoutPosition)
true
}
}
}
}
}
@@ -1,204 +0,0 @@
package io.legado.app.ui.book.read.config
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogRegexColorConfigBinding
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.RegexColorRule
import io.legado.app.lib.dialogs.alert
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.page.provider.TextChapterLayout
import io.legado.app.ui.widget.AccentColorButton
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
import com.jaredrummler.android.colorpicker.ColorPickerDialog
class RegexColorConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_regex_color_config),
FontSelectDialog.CallBack {
private val binding by viewBinding(DialogRegexColorConfigBinding::bind)
private val callBack2 get() = activity as? ReadBookActivity
private lateinit var adapter: RegexColorRuleAdapter
private var editingRulePosition = -1
companion object {
const val REGEX_RULE_COLOR = 7900
var pendingColorPosition = -1
}
override val curFontPath: String
get() = if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
ReadBookConfig.regexColorRules[editingRulePosition].fontPath
} else ""
override fun selectFont(path: String) {
if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
ReadBookConfig.regexColorRules[editingRulePosition].fontPath = path
notifyConfigChanged()
}
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
adapter = RegexColorRuleAdapter(
onDeleteClick = { position -> deleteRule(position) },
onColorClick = { position -> showColorPicker(position) },
onFontClick = { position -> showFontSelect(position) }
)
initView()
initViewEvent()
}
private fun initView() = binding.run {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = adapter
adapter.setItems(ReadBookConfig.regexColorRules)
}
private fun initViewEvent() = binding.run {
btnAddRule.setOnClickListener {
showAddRuleDialog()
}
}
private fun showAddRuleDialog() {
val defaultPatterns = listOf(
"\u201C匹配内容\u201D" to "\u201C.+?\u201D",
"《匹配内容》" to "《.+?》",
"\"匹配内容\"" to "\".+?\""
)
val displayItems = defaultPatterns.map { it.first } + "自定义规则"
context?.alert(title = "添加正则规则") {
items(displayItems) { _, i ->
if (i < defaultPatterns.size) {
val (name, pattern) = defaultPatterns[i]
addRule(name, pattern)
} else {
showCustomRuleDialog()
}
}
}
}
private fun showCustomRuleDialog() {
val editText = android.widget.EditText(context).apply {
hint = "输入正则表达式,如:\\u201C.+?\\u201D"
}
context?.alert(title = "自定义正则规则") {
customView { editText }
okButton {
val pattern = editText.text.toString().trim()
if (pattern.isNotEmpty()) {
addRule(pattern, pattern)
}
}
cancelButton()
}
}
private fun addRule(name: String, pattern: String) {
val rule = RegexColorRule(name, pattern, ReadBookConfig.durConfig.curTextAccentColor())
ReadBookConfig.regexColorRules.add(rule)
notifyConfigChanged()
}
private fun deleteRule(position: Int) {
if (position >= 0 && position < ReadBookConfig.regexColorRules.size) {
ReadBookConfig.regexColorRules.removeAt(position)
notifyConfigChanged()
}
}
private fun showColorPicker(position: Int) {
if (position !in ReadBookConfig.regexColorRules.indices) return
editingRulePosition = position
pendingColorPosition = position
val rule = ReadBookConfig.regexColorRules[position]
val colorValue = rule.color or 0xFF000000.toInt()
ColorPickerDialog.newBuilder()
.setColor(colorValue)
.setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM)
.setDialogId(REGEX_RULE_COLOR)
.show(requireActivity())
}
private fun showFontSelect(position: Int) {
if (position !in ReadBookConfig.regexColorRules.indices) return
editingRulePosition = position
FontSelectDialog().apply {
explicitCallback = this@RegexColorConfigDialog
}.show(childFragmentManager, "regexFontSelect")
}
fun onColorSelected(color: Int) {
if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
ReadBookConfig.regexColorRules[editingRulePosition].color = color
notifyConfigChanged()
}
}
private fun notifyConfigChanged() {
ReadBookConfig.saveRegexColorRules()
TextChapterLayout.invalidateRegexCache()
adapter.setItems(ReadBookConfig.regexColorRules)
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
class RegexColorRuleAdapter(
private val onDeleteClick: ((Int) -> Unit)? = null,
private val onColorClick: ((Int) -> Unit)? = null,
private val onFontClick: ((Int) -> Unit)? = null
) : RecyclerView.Adapter<RegexColorRuleAdapter.ViewHolder>() {
private var items: List<RegexColorRule> = emptyList()
fun setItems(items: List<RegexColorRule>) {
this.items = items
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_regex_color_rule, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.bind(item, position)
}
override fun getItemCount(): Int {
return items.size
}
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvRuleName = itemView.findViewById<android.widget.TextView>(R.id.tv_rule_name)
val tvRulePattern = itemView.findViewById<android.widget.TextView>(R.id.tv_rule_pattern)
val btnSelectFont = itemView.findViewById<com.google.android.material.button.MaterialButton>(R.id.btn_select_font)
val btnSelectColor = itemView.findViewById<AccentColorButton>(R.id.btn_select_color)
val btnDelete = itemView.findViewById<android.widget.ImageButton>(R.id.btn_delete)
fun bind(item: RegexColorRule, position: Int) {
tvRuleName.text = item.name
tvRulePattern.text = item.pattern
btnSelectColor.color = item.color or 0xFF000000.toInt()
btnSelectFont.setOnClickListener {
onFontClick?.invoke(position)
}
btnSelectColor.setOnClickListener {
onColorClick?.invoke(position)
}
btnDelete.setOnClickListener {
onDeleteClick?.invoke(position)
}
}
}
}
@@ -1,66 +0,0 @@
package io.legado.app.ui.book.read.config
import android.os.Bundle
import android.view.View
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.constant.EventBus
import io.legado.app.databinding.DialogShadowSetBinding
import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding
/**
* 字体选择对话框
*/
class ShadowSetDialog : BaseBottomSheetDialogFragment(R.layout.dialog_shadow_set) {
companion object {
const val S_COLOR = 123
}
private val fontRegex = Regex("(?i).*\\.[ot]tf")
private val binding by viewBinding(DialogShadowSetBinding::bind)
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
upView()
initViewEvent()
}
private fun initView() = binding.run {
binding.dsbShadowRadius.valueFormat = { "$it px" }
binding.dsbShadowDx.valueFormat = { "$it px" }
binding.dsbShadowDy.valueFormat = { "$it px" }
}
private fun initViewEvent() = binding.run {
binding.dsbShadowRadius.onChanged = {
ReadBookConfig.shadowRadius = it.toFloat()
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.dsbShadowDx.onChanged = {
ReadBookConfig.shadowDx = it.toFloat()
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
binding.dsbShadowDy.onChanged = {
ReadBookConfig.shadowDy = it.toFloat()
postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
}
}
private fun upView() = binding.run {
ReadBookConfig.let {
binding.dsbShadowRadius.progress = it.shadowRadius.toInt()
binding.dsbShadowDx.progress = it.shadowDx.toInt()
binding.dsbShadowDy.progress = it.shadowDy.toInt()
}
}
private val callBack: CallBack?
get() = (parentFragment as? CallBack) ?: (activity as? CallBack)
interface CallBack {
fun selectFont(path: String)
val curFontPath: String
}
}
@@ -0,0 +1,62 @@
package io.legado.app.ui.book.read.config
import android.graphics.Canvas
import android.graphics.Paint
import android.text.style.ReplacementSpan
import io.legado.app.utils.dpToPx
/**
* 实线下划线 Span
*/
class SolidUnderlineSpan(
private val textColor: Int,
private val underlineColor: Int,
private val underlineWidth: Float = 1f,
private val underlineOffset: Float = 6f,
) : ReplacementSpan() {
private val offsetPx = underlineOffset.toInt().dpToPx()
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if (fm != null) {
val metrics = paint.fontMetricsInt
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent + offsetPx
fm.bottom = metrics.bottom + offsetPx
}
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val textStr = text.subSequence(start, end).toString()
paint.color = textColor
canvas.drawText(textStr, x, y.toFloat(), paint)
val width = paint.measureText(text, start, end)
val lineY = y + offsetPx
val linePaint = Paint(paint).apply {
color = underlineColor
style = Paint.Style.STROKE
strokeWidth = underlineWidth.dpToPx()
isAntiAlias = true
}
canvas.drawLine(x, lineY.toFloat(), x + width, lineY.toFloat(), linePaint)
}
}
@@ -1,299 +0,0 @@
package io.legado.app.ui.book.read.config
//import io.legado.app.lib.theme.primaryColor
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.RadioButton
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import io.legado.app.R
import io.legado.app.base.BaseBottomSheetDialogFragment
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.base.adapter.RecyclerAdapter
import io.legado.app.constant.AppLog
import io.legado.app.data.appDb
import io.legado.app.data.entities.HttpTTS
import io.legado.app.databinding.DialogEditTextBinding
import io.legado.app.databinding.DialogRecyclerViewBinding
import io.legado.app.databinding.ItemHttpTtsBinding
import io.legado.app.help.DirectLinkUpload
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.model.ReadAloud
import io.legado.app.model.ReadBook
import io.legado.app.ui.association.ImportHttpTtsDialog
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.utils.ACache
import io.legado.app.utils.GSON
import io.legado.app.utils.TTSCacheUtils
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.gone
import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
/**
* tts引擎管理
*/
class SpeakEngineDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view),
Toolbar.OnMenuItemClickListener {
private val binding by viewBinding(DialogRecyclerViewBinding::bind)
private val viewModel: SpeakEngineViewModel by viewModels()
private val ttsUrlKey = "ttsUrlKey"
private val adapter by lazy { Adapter(requireContext()) }
private var ttsEngine: String? = ReadAloud.ttsEngine
private val sysTtsViews = arrayListOf<RadioButton>()
private val callBack: CallBack? get() = parentFragment as? CallBack
private val importDocResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
showDialogFragment(ImportHttpTtsDialog(uri.toString()))
}
}
private val exportDirResult = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri ->
alert(R.string.export_success) {
if (uri.toString().isAbsUrl()) {
setMessage(DirectLinkUpload.getSummary())
}
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = getString(R.string.path)
editView.setText(uri.toString())
}
customView { alertBinding.root }
okButton {
requireContext().sendToClip(uri.toString())
}
}
}
}
override fun onStart() {
super.onStart()
//setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f)
}
override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView()
initMenu()
initData()
}
private fun initView() = binding.run {
//toolBar.setBackgroundColor(primaryColor)
toolBar.setTitle(R.string.speak_engine)
//recyclerView.setEdgeEffectColor(primaryColor)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
recyclerView.adapter = adapter
adapter.addHeaderView {
ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply {
sysTtsViews.add(cbName)
ivEdit.gone()
ivMenuDelete.gone()
labelSys.visible()
cbName.text = "系统默认"
cbName.tag = ""
cbName.isChecked = ttsEngine == null || ttsEngine!!.isJsonObject()
&& GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value.isNullOrEmpty()
cbName.setOnClickListener {
upTts(GSON.toJson(SelectItem("系统默认", "")))
}
}
}
viewModel.sysEngines.forEach { engine ->
adapter.addHeaderView {
ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply {
sysTtsViews.add(cbName)
ivEdit.gone()
ivMenuDelete.gone()
labelSys.visible()
cbName.text = engine.label
cbName.tag = engine.name
cbName.isChecked = GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value == cbName.tag
cbName.setOnClickListener {
upTts(GSON.toJson(SelectItem(engine.label, engine.name)))
}
}
}
}
tvFooterLeft.setText(R.string.book)
tvFooterLeft.visible()
tvFooterLeft.setOnClickListener {
ReadBook.book?.setTtsEngine(ttsEngine)
callBack?.upSpeakEngineSummary()
ReadAloud.upReadAloudClass()
dismissAllowingStateLoss()
}
tvOk.setText(R.string.general)
tvOk.visible()
tvOk.setOnClickListener {
ReadBook.book?.setTtsEngine(null)
AppConfig.ttsEngine = ttsEngine
callBack?.upSpeakEngineSummary()
ReadAloud.upReadAloudClass()
dismissAllowingStateLoss()
}
tvCancel.visible()
tvCancel.setOnClickListener {
dismissAllowingStateLoss()
}
}
private fun initMenu() = binding.run {
toolBar.inflateMenu(R.menu.speak_engine)
//toolBar.menu.applyTint(requireContext())
toolBar.setOnMenuItemClickListener(this@SpeakEngineDialog)
}
private fun initData() {
lifecycleScope.launch {
appDb.httpTTSDao.flowAll().catch {
AppLog.put("朗读引擎界面获取数据失败\n${it.localizedMessage}", it)
}.flowOn(IO).conflate().collect {
adapter.setItems(it)
}
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_clear -> clearCache()
R.id.menu_add -> showDialogFragment<HttpTtsEditDialog>()
R.id.menu_default -> viewModel.importDefault()
R.id.menu_import_local -> importDocResult.launch {
mode = HandleFileContract.FILE
allowExtensions = arrayOf("txt", "json")
}
R.id.menu_import_onLine -> importAlert()
R.id.menu_export -> exportDirResult.launch {
mode = HandleFileContract.EXPORT
fileData = HandleFileContract.FileData(
"httpTts.json",
GSON.toJson(adapter.getItems()).toByteArray(),
"application/json"
)
}
}
return true
}
private fun importAlert() {
val aCache = ACache.get(cacheDir = false)
val cacheUrls: MutableList<String> = aCache
.getAsString(ttsUrlKey)
?.splitNotBlank(",")
?.toMutableList() ?: mutableListOf()
alert(R.string.import_on_line) {
val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
editView.hint = "url"
editView.setFilterValues(cacheUrls)
editView.delCallBack = {
cacheUrls.remove(it)
aCache.put(ttsUrlKey, cacheUrls.joinToString(","))
}
}
customView { alertBinding.root }
okButton {
alertBinding.editView.text?.toString()?.let { url ->
if (url.isAbsUrl() && !cacheUrls.contains(url)) {
cacheUrls.add(0, url)
aCache.put(ttsUrlKey, cacheUrls.joinToString(","))
}
showDialogFragment(ImportHttpTtsDialog(url))
}
}
}
}
private fun upTts(tts: String) {
ttsEngine = tts
sysTtsViews.forEach {
it.isChecked = GSON.fromJsonObject<SelectItem<String>>(ttsEngine)
.getOrNull()?.value == it.tag
}
adapter.notifyItemRangeChanged(adapter.getHeaderCount(), adapter.itemCount)
}
fun clearCache() {
execute {
ReadAloud.upReadAloudClass()
TTSCacheUtils.clearTtsCache()
toastOnUi(R.string.clear_cache_success)
}
}
inner class Adapter(context: Context) :
RecyclerAdapter<HttpTTS, ItemHttpTtsBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemHttpTtsBinding {
return ItemHttpTtsBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemHttpTtsBinding,
item: HttpTTS,
payloads: MutableList<Any>
) {
binding.apply {
cbName.text = item.name
cbName.isChecked = item.id.toString() == ttsEngine
}
}
override fun registerListener(holder: ItemViewHolder, binding: ItemHttpTtsBinding) {
binding.run {
cbName.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS ->
val id = httpTTS.id.toString()
upTts(id)
if (!httpTTS.loginUrl.isNullOrBlank()
&& httpTTS.getLoginInfo().isNullOrBlank()
) {
startActivity<SourceLoginActivity> {
putExtra("type", "httpTts")
putExtra("key", id)
}
}
}
}
ivEdit.setOnClickListener {
val id = getItemByLayoutPosition(holder.layoutPosition)!!.id
showDialogFragment(HttpTtsEditDialog(id))
}
ivMenuDelete.setOnClickListener {
getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS ->
appDb.httpTTSDao.delete(httpTTS)
}
}
}
}
}
interface CallBack {
fun upSpeakEngineSummary()
}
}
@@ -1,23 +0,0 @@
package io.legado.app.ui.book.read.config
import android.app.Application
import android.speech.tts.TextToSpeech
import io.legado.app.base.BaseViewModel
import io.legado.app.help.DefaultData
class SpeakEngineViewModel(application: Application) : BaseViewModel(application) {
val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
val tts = TextToSpeech(context, null)
val engines = tts.engines
tts.shutdown()
engines
}
fun importDefault() {
execute {
DefaultData.importDefaultHttpTTS()
}
}
}
@@ -0,0 +1,410 @@
package io.legado.app.ui.book.read.config
import android.graphics.Path
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
object SvgPathParser {
private val cache = android.util.LruCache<String, Path>(32)
fun parse(svgPath: String): Path? {
if (svgPath.isBlank()) return null
cache.get(svgPath)?.let { return it }
val path = parseInternal(svgPath) ?: return null
cache.put(svgPath, path)
return path
}
private fun parseInternal(svgPath: String): Path? {
if (svgPath.isBlank()) return null
val path = Path()
val tokens = tokenize(svgPath)
if (tokens.isEmpty()) return null
var currentX = 0f
var currentY = 0f
var startX = 0f
var startY = 0f
var lastControlX = 0f
var lastControlY = 0f
var lastCommand = ""
var index = 0
while (index < tokens.size) {
val token = tokens[index]
when (token) {
"M" -> {
index++
if (index + 1 < tokens.size) {
currentX = tokens[index].toFloatOrNull() ?: currentX
currentY = tokens[index + 1].toFloatOrNull() ?: currentY
startX = currentX
startY = currentY
path.moveTo(currentX, currentY)
index += 2
}
lastCommand = "M"
}
"m" -> {
index++
if (index + 1 < tokens.size) {
val dx = tokens[index].toFloatOrNull() ?: 0f
val dy = tokens[index + 1].toFloatOrNull() ?: 0f
currentX += dx
currentY += dy
startX = currentX
startY = currentY
path.moveTo(currentX, currentY)
index += 2
}
lastCommand = "m"
}
"L" -> {
index++
while (index + 1 < tokens.size && !isCommand(tokens[index])) {
currentX = tokens[index].toFloatOrNull() ?: currentX
currentY = tokens[index + 1].toFloatOrNull() ?: currentY
path.lineTo(currentX, currentY)
index += 2
}
lastCommand = "L"
}
"l" -> {
index++
while (index + 1 < tokens.size && !isCommand(tokens[index])) {
val dx = tokens[index].toFloatOrNull() ?: 0f
val dy = tokens[index + 1].toFloatOrNull() ?: 0f
currentX += dx
currentY += dy
path.lineTo(currentX, currentY)
index += 2
}
lastCommand = "l"
}
"H" -> {
index++
while (index < tokens.size && !isCommand(tokens[index])) {
currentX = tokens[index].toFloatOrNull() ?: currentX
path.lineTo(currentX, currentY)
index++
}
lastCommand = "H"
}
"h" -> {
index++
while (index < tokens.size && !isCommand(tokens[index])) {
val dx = tokens[index].toFloatOrNull() ?: 0f
currentX += dx
path.lineTo(currentX, currentY)
index++
}
lastCommand = "h"
}
"V" -> {
index++
while (index < tokens.size && !isCommand(tokens[index])) {
currentY = tokens[index].toFloatOrNull() ?: currentY
path.lineTo(currentX, currentY)
index++
}
lastCommand = "V"
}
"v" -> {
index++
while (index < tokens.size && !isCommand(tokens[index])) {
val dy = tokens[index].toFloatOrNull() ?: 0f
currentY += dy
path.lineTo(currentX, currentY)
index++
}
lastCommand = "v"
}
"C" -> {
index++
while (index + 5 < tokens.size && !isCommand(tokens[index])) {
val x1 = tokens[index].toFloatOrNull() ?: currentX
val y1 = tokens[index + 1].toFloatOrNull() ?: currentY
val x2 = tokens[index + 2].toFloatOrNull() ?: currentX
val y2 = tokens[index + 3].toFloatOrNull() ?: currentY
val x = tokens[index + 4].toFloatOrNull() ?: currentX
val y = tokens[index + 5].toFloatOrNull() ?: currentY
path.cubicTo(x1, y1, x2, y2, x, y)
lastControlX = x2
lastControlY = y2
currentX = x
currentY = y
index += 6
}
lastCommand = "C"
}
"c" -> {
index++
while (index + 5 < tokens.size && !isCommand(tokens[index])) {
val x1 = currentX + (tokens[index].toFloatOrNull() ?: 0f)
val y1 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f)
val x2 = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f)
val y2 = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f)
val x = currentX + (tokens[index + 4].toFloatOrNull() ?: 0f)
val y = currentY + (tokens[index + 5].toFloatOrNull() ?: 0f)
path.cubicTo(x1, y1, x2, y2, x, y)
lastControlX = x2
lastControlY = y2
currentX = x
currentY = y
index += 6
}
lastCommand = "c"
}
"S" -> {
index++
while (index + 3 < tokens.size && !isCommand(tokens[index])) {
val x2 = tokens[index].toFloatOrNull() ?: currentX
val y2 = tokens[index + 1].toFloatOrNull() ?: currentY
val x = tokens[index + 2].toFloatOrNull() ?: currentX
val y = tokens[index + 3].toFloatOrNull() ?: currentY
val x1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") {
2 * currentX - lastControlX
} else {
currentX
}
val y1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") {
2 * currentY - lastControlY
} else {
currentY
}
path.cubicTo(x1, y1, x2, y2, x, y)
lastControlX = x2
lastControlY = y2
currentX = x
currentY = y
index += 4
}
lastCommand = "S"
}
"s" -> {
index++
while (index + 3 < tokens.size && !isCommand(tokens[index])) {
val x2 = currentX + (tokens[index].toFloatOrNull() ?: 0f)
val y2 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f)
val x = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f)
val y = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f)
val x1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") {
2 * currentX - lastControlX
} else {
currentX
}
val y1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") {
2 * currentY - lastControlY
} else {
currentY
}
path.cubicTo(x1, y1, x2, y2, x, y)
lastControlX = x2
lastControlY = y2
currentX = x
currentY = y
index += 4
}
lastCommand = "s"
}
"Q" -> {
index++
while (index + 3 < tokens.size && !isCommand(tokens[index])) {
val x1 = tokens[index].toFloatOrNull() ?: currentX
val y1 = tokens[index + 1].toFloatOrNull() ?: currentY
val x = tokens[index + 2].toFloatOrNull() ?: currentX
val y = tokens[index + 3].toFloatOrNull() ?: currentY
path.quadTo(x1, y1, x, y)
lastControlX = x1
lastControlY = y1
currentX = x
currentY = y
index += 4
}
lastCommand = "Q"
}
"q" -> {
index++
while (index + 3 < tokens.size && !isCommand(tokens[index])) {
val x1 = currentX + (tokens[index].toFloatOrNull() ?: 0f)
val y1 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f)
val x = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f)
val y = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f)
path.quadTo(x1, y1, x, y)
lastControlX = x1
lastControlY = y1
currentX = x
currentY = y
index += 4
}
lastCommand = "q"
}
"A", "a" -> {
val isRelative = token == "a"
index++
while (index + 6 < tokens.size && !isCommand(tokens[index])) {
val arcRx = abs(tokens[index].toFloatOrNull() ?: 0f)
val arcRy = abs(tokens[index + 1].toFloatOrNull() ?: 0f)
val xAxisRotation = tokens[index + 2].toFloatOrNull() ?: 0f
val largeArcFlag = tokens[index + 3].toIntOrNull() ?: 0
val sweepFlag = tokens[index + 4].toIntOrNull() ?: 0
val x = if (isRelative) currentX + (tokens[index + 5].toFloatOrNull() ?: 0f) else tokens[index + 5].toFloatOrNull() ?: currentX
val y = if (isRelative) currentY + (tokens[index + 6].toFloatOrNull() ?: 0f) else tokens[index + 6].toFloatOrNull() ?: currentY
drawArc(path, currentX, currentY, x, y, arcRx, arcRy, xAxisRotation, largeArcFlag == 1, sweepFlag == 1)
currentX = x
currentY = y
index += 7
}
lastCommand = token
}
"Z", "z" -> {
path.close()
currentX = startX
currentY = startY
index++
lastCommand = token
}
else -> {
index++
}
}
}
return path
}
private fun tokenize(svgPath: String): List<String> {
val tokens = mutableListOf<String>()
val sb = StringBuilder()
var i = 0
while (i < svgPath.length) {
val c = svgPath[i]
when {
c.isWhitespace() || c == ',' -> {
if (sb.isNotEmpty()) {
tokens.add(sb.toString())
sb.clear()
}
}
c.isLetter() -> {
if (sb.isNotEmpty()) {
tokens.add(sb.toString())
sb.clear()
}
tokens.add(c.toString())
}
c == '-' -> {
if (sb.isNotEmpty() && !sb.endsWith('e', ignoreCase = true)) {
tokens.add(sb.toString())
sb.clear()
}
sb.append(c)
}
c == '.' -> {
if (sb.contains('.')) {
tokens.add(sb.toString())
sb.clear()
}
sb.append(c)
}
else -> {
sb.append(c)
}
}
i++
}
if (sb.isNotEmpty()) {
tokens.add(sb.toString())
}
return tokens
}
private fun isCommand(token: String): Boolean {
return token.length == 1 && token[0].isLetter()
}
private fun drawArc(
path: Path,
x1: Float, y1: Float,
x2: Float, y2: Float,
arcRx: Float, arcRy: Float,
phi: Float,
largeArc: Boolean,
sweep: Boolean
) {
if (arcRx == 0f || arcRy == 0f) {
path.lineTo(x2, y2)
return
}
var localRx = arcRx
var localRy = arcRy
val phiRad = Math.toRadians(phi.toDouble())
val cosPhi = cos(phiRad).toFloat()
val sinPhi = sin(phiRad).toFloat()
val dx = (x1 - x2) / 2f
val dy = (y1 - y2) / 2f
val x1p = cosPhi * dx + sinPhi * dy
val y1p = -sinPhi * dx + cosPhi * dy
var rxSq = localRx * localRx
var rySq = localRy * localRy
val x1pSq = x1p * x1p
val y1pSq = y1p * y1p
var cr = x1pSq / rxSq + y1pSq / rySq
if (cr > 1f) {
val sqrtCr = kotlin.math.sqrt(cr.toDouble()).toFloat()
localRx *= sqrtCr
localRy *= sqrtCr
rxSq = localRx * localRx
rySq = localRy * localRy
}
val rq = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq
val cq = rxSq * y1pSq + rySq * x1pSq
val sqrtVal = kotlin.math.sqrt(kotlin.math.max(0.0, rq.toDouble()) / kotlin.math.max(1e-10, cq.toDouble())).toFloat()
val sign = if (largeArc != sweep) 1f else -1f
val cxp = sign * sqrtVal * (localRx * y1p / localRy)
val cyp = -sign * sqrtVal * (localRy * x1p / localRx)
val cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2f
val cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2f
val theta1 = angle(1f, 0f, (x1p - cxp) / localRx, (y1p - cyp) / localRy)
var dtheta = angle(
(x1p - cxp) / localRx, (y1p - cyp) / localRy,
(-x1p - cxp) / localRx, (-y1p - cyp) / localRy
)
if (!sweep && dtheta > 0) dtheta -= 360f
if (sweep && dtheta < 0) dtheta += 360f
val sweepAngle = dtheta
path.arcTo(
android.graphics.RectF(cx - localRx, cy - localRy, cx + localRx, cy + localRy),
theta1,
sweepAngle,
false
)
}
private fun angle(ux: Float, uy: Float, vx: Float, vy: Float): Float {
val n = kotlin.math.sqrt((ux * ux + uy * uy).toDouble()) * kotlin.math.sqrt((vx * vx + vy * vy).toDouble())
val c = (ux * vx + uy * vy) / kotlin.math.max(n, 1e-10)
val angle = Math.toDegrees(kotlin.math.acos(c.coerceIn(-1.0, 1.0)))
return if (ux * vy - uy * vx < 0) -angle.toFloat() else angle.toFloat()
}
}
@@ -0,0 +1,75 @@
package io.legado.app.ui.book.read.config
import android.graphics.Canvas
import android.graphics.Paint
import android.text.style.ReplacementSpan
import io.legado.app.utils.dpToPx
/**
* SVG 路径下划线 Span
*/
class SvgUnderlineSpan(
private val textColor: Int,
private val underlineColor: Int,
private val underlineWidth: Float = 1f,
private val svgPath: String,
) : ReplacementSpan() {
override fun getSize(
paint: Paint,
text: CharSequence,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
if (fm != null) {
val metrics = paint.fontMetricsInt
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent
fm.bottom = metrics.bottom
}
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas,
text: CharSequence,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
val textStr = text.subSequence(start, end).toString()
val textPaint = Paint(paint).apply {
color = textColor
}
canvas.drawText(textStr, x, y.toFloat(), textPaint)
if (svgPath.isNotBlank()) {
val path = SvgPathParser.parse(svgPath)
if (path != null) {
val textWidth = paint.measureText(textStr)
val baseWidth = 100f
val baseY = 50f
val lineY = y + 6.dpToPx()
val underlinePaint = Paint(paint).apply {
color = underlineColor
strokeWidth = underlineWidth.dpToPx()
style = Paint.Style.STROKE
isAntiAlias = true
}
canvas.save()
canvas.translate(x, lineY - baseY)
canvas.scale(textWidth / baseWidth, 1f)
canvas.drawPath(path, underlinePaint)
canvas.restore()
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More