Sfoglia il codice sorgente

Merge branch 'main' of https://github.com/HapeLee/legado-with-MD3

HapeLee 5 mesi fa
parent
commit
429667d74a
21 ha cambiato i file con 680 aggiunte e 43 eliminazioni
  1. 12 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 15 0
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  3. 5 2
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaAdapter.kt
  4. 167 0
      app/src/main/java/io/legado/app/ui/book/read/EyeProtectionHelper.kt
  5. 53 0
      app/src/main/java/io/legado/app/ui/book/read/EyeProtectionOverlay.kt
  6. 11 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  7. 4 0
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  8. 27 2
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  9. 38 20
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  10. 6 1
      app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
  11. 23 0
      app/src/main/java/io/legado/app/ui/config/labConfig/LabConfig.kt
  12. 108 0
      app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigScreen.kt
  13. 17 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  14. 119 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  15. 7 1
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  16. 4 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  17. 8 0
      app/src/main/res/layout/activity_book_read.xml
  18. 8 1
      app/src/main/res/layout/activity_manga.xml
  19. 23 0
      app/src/main/res/values-zh-rCN/strings.xml
  20. 23 0
      app/src/main/res/values/strings.xml
  21. 2 15
      modules/web/src/store/bookStore.ts

+ 12 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -338,4 +338,16 @@ object PreferKey {
     const val homepageModuleHidden = "homepageModuleHidden"
     const val homepageLayoutMode = "homepageLayoutMode"
     const val navIconHome = "navIconHome"
+
+    // Lab
+    const val labEnabled = "labEnabled"
+    const val labEInkDisplay = "labEInkDisplay"
+    const val labEyeProtection = "labEyeProtection"
+
+    // Eye Protection
+    const val eyeProtectionEnabled = "eyeProtectionEnabled"
+    const val colorTemperature = "colorTemperature"
+    const val eyeProtectionSchedule = "eyeProtectionSchedule"
+    const val eyeProtectionStartTime = "eyeProtectionStartTime"
+    const val eyeProtectionEndTime = "eyeProtectionEndTime"
 }

+ 15 - 0
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -70,7 +70,9 @@ import io.legado.app.ui.book.manga.recyclerview.MangaAdapter
 import io.legado.app.ui.book.manga.recyclerview.MangaLayoutManager
 import io.legado.app.ui.book.manga.recyclerview.ScrollTimer
 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
@@ -81,6 +83,7 @@ import io.legado.app.ui.widget.recycler.LoadMoreView
 import io.legado.app.utils.GSON
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.canScroll
 import io.legado.app.utils.fastBinarySearch
 import io.legado.app.utils.findCenterViewPosition
@@ -138,6 +141,10 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     private var justInitData: Boolean = false
     private var syncDialog: AlertDialog? = null
+    private val handler by lazy { buildMainHandler() }
+    private val eyeProtectionScheduler by lazy {
+        EyeProtectionRefreshScheduler(handler) { binding.eyeProtectionOverlay.refresh() }
+    }
     private val mScrollTimer by lazy {
         ScrollTimer(this, binding.recyclerView, lifecycleScope).apply {
             setSpeed(AppConfig.mangaAutoPageSpeed)
@@ -213,6 +220,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             ReadManga.loadOrUpContent()
         }
         binding.flLoading.isVisible = !AppConfig.isEInkMode
+        binding.eyeProtectionOverlay.refresh()
         mAdapter.addFooterView {
             ViewLoadMoreBinding.bind(loadMoreView)
         }
@@ -242,6 +250,10 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             val item = mAdapter.getItem(binding.recyclerView.findCenterViewPosition())
             upInfoBar(item)
         }
+        observeEyeProtectionEvents(
+            onRefresh = { binding.eyeProtectionOverlay.refresh() },
+            scheduler = eyeProtectionScheduler
+        )
     }
 
     private fun initRecyclerView() {
@@ -457,10 +469,13 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
         }
         ReadManga.initReadTime()
         ReadManga.startAutoSaveSession()
+        binding.eyeProtectionOverlay.refresh()
+        eyeProtectionScheduler.schedule()
     }
 
     override fun onPause() {
         super.onPause()
+        eyeProtectionScheduler.cancel()
         if (ReadManga.inBookshelf) {
             ReadManga.saveRead()
             if (!BuildConfig.DEBUG) {

+ 5 - 2
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaAdapter.kt

@@ -122,13 +122,16 @@ class MangaAdapter(private val context: Context) :
             ) {
                 "ARGB values must be between 0-255"
             }
-            val matrix = floatArrayOf(
+            val baseMatrix = floatArrayOf(
                 (255 - mConfig.r) / 255f, 0f, 0f, 0f, 0f,
                 0f, (255 - mConfig.g) / 255f, 0f, 0f, 0f,
                 0f, 0f, (255 - mConfig.b) / 255f, 0f, 0f,
                 0f, 0f, 0f, (255 - mConfig.a) / 255f, 0f
             )
-            binding.image.colorFilter = ColorMatrixColorFilter(ColorMatrix(matrix))
+            val epMatrix = io.legado.app.ui.book.read.EyeProtectionHelper.buildColorMatrix()
+            val combined = android.graphics.ColorMatrix(baseMatrix)
+            combined.postConcat(epMatrix)
+            binding.image.colorFilter = ColorMatrixColorFilter(combined)
         }
 
         fun setBackground() {

+ 167 - 0
app/src/main/java/io/legado/app/ui/book/read/EyeProtectionHelper.kt

@@ -0,0 +1,167 @@
+package io.legado.app.ui.book.read
+
+import android.graphics.Color
+import android.graphics.ColorMatrix
+import android.graphics.Paint
+import android.os.Handler
+import androidx.appcompat.app.AppCompatActivity
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.labConfig.LabConfig
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.utils.observeEvent
+import java.util.Calendar
+
+/**
+ * 护眼色温工具类
+ * 提供色温(暖色)转 ColorMatrix 以及覆盖层颜色计算
+ *
+ * 激活条件:
+ * Lab 总开关开启 && 实验室护眼模式开启 && 主题护眼模式开启 &&
+ * (未开启定时 OR 当前时间在定时区间内)
+ */
+object EyeProtectionHelper {
+
+    /**
+     * 判断当前时间是否在护眼定时区间内
+     */
+    fun isInSchedule(): Boolean {
+        if (!ThemeConfig.eyeProtectionSchedule) return true
+        val now = Calendar.getInstance()
+        val curMinute = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
+        val start = parseTime(ThemeConfig.eyeProtectionStartTime)
+        val end = parseTime(ThemeConfig.eyeProtectionEndTime)
+        if (start == null || end == null) return true
+        return if (start == end) {
+            true
+        } else if (start < end) {
+            curMinute in start..end
+        } else {
+            curMinute >= start || curMinute <= end
+        }
+    }
+
+    private fun parseTime(value: String?): Int? {
+        if (value.isNullOrBlank()) return null
+        return try {
+            val parts = value.split(":")
+            if (parts.size < 2) return null
+            val h = parts[0].trim().toInt().coerceIn(0, 23)
+            val m = parts[1].trim().toInt().coerceIn(0, 59)
+            h * 60 + m
+        } catch (e: Exception) {
+            null
+        }
+    }
+
+    /**
+     * 是否应该激活护眼色温
+     * 仅当总开关打开,并且满足定时条件时返回 true
+     */
+    fun isActive(): Boolean {
+        if (!LabConfig.labEnabled) return false
+        if (!LabConfig.eyeProtection) return false
+        if (!ThemeConfig.eyeProtectionEnabled) return false
+        return isInSchedule()
+    }
+
+    /**
+     * 根据当前色温设置获取暖色覆盖颜色
+     * temperature: 0-100,0 不变,100 最暖
+     */
+    fun overlayColor(): Int {
+        if (!isActive()) return Color.TRANSPARENT
+        val t = ThemeConfig.colorTemperature.coerceIn(0, 100)
+        if (t == 0) return Color.TRANSPARENT
+        val alpha = (t / 100f * 90f).toInt().coerceIn(0, 255)
+        return Color.argb(alpha, 255, 180, 90)
+    }
+
+    /**
+     * 创建色温 ColorMatrix(暖色调,降低蓝光通道)
+     */
+    fun buildColorMatrix(): ColorMatrix {
+        val t = if (isActive()) ThemeConfig.colorTemperature.coerceIn(0, 100) else 0
+        val ratio = t / 100f
+        val r = 1f
+        val g = 1f - 0.45f * ratio
+        val b = 1f - 0.85f * ratio
+        val brightness = 1f - 0.05f * ratio
+        val matrix = ColorMatrix(
+            floatArrayOf(
+                r * brightness, 0f, 0f, 0f, 8f * ratio,
+                0f, g * brightness, 0f, 0f, 4f * ratio,
+                0f, 0f, b * brightness, 0f, 0f,
+                0f, 0f, 0f, 1f, 0f
+            )
+        )
+        return matrix
+    }
+
+    /**
+     * 为 Paint 应用护眼色温 ColorFilter
+     */
+    fun applyColorFilter(paint: Paint) {
+        if (isActive()) {
+            paint.colorFilter = android.graphics.ColorMatrixColorFilter(buildColorMatrix())
+        } else {
+            paint.colorFilter = null
+        }
+    }
+}
+
+/**
+ * 护眼定时刷新调度器
+ * 在定时区间边界(每分钟)自动刷新护眼状态
+ */
+class EyeProtectionRefreshScheduler(
+    private val handler: Handler,
+    private val onRefresh: () -> Unit
+) {
+    private val refreshRunnable = Runnable {
+        onRefresh()
+        schedule()
+    }
+
+    fun schedule() {
+        cancel()
+        if (!ThemeConfig.eyeProtectionSchedule) return
+        val now = Calendar.getInstance()
+        val delayMs = (60 - now.get(Calendar.SECOND)) * 1000L -
+            now.get(Calendar.MILLISECOND)
+        val safeDelay = delayMs.coerceAtLeast(1000L)
+        handler.postDelayed(refreshRunnable, safeDelay)
+    }
+
+    fun cancel() {
+        handler.removeCallbacks(refreshRunnable)
+    }
+}
+
+/**
+ * 在 Activity 中注册护眼模式相关的 EventBus 监听
+ * 设置变化时自动刷新覆盖层,定时/时间变化时重新调度
+ */
+fun AppCompatActivity.observeEyeProtectionEvents(
+    onRefresh: () -> Unit,
+    scheduler: EyeProtectionRefreshScheduler
+) {
+    observeEvent<Boolean>(PreferKey.eyeProtectionEnabled) {
+        onRefresh()
+        scheduler.schedule()
+    }
+    observeEvent<Int>(PreferKey.colorTemperature) {
+        onRefresh()
+    }
+    observeEvent<Boolean>(PreferKey.eyeProtectionSchedule) {
+        onRefresh()
+        scheduler.schedule()
+    }
+    observeEvent<String>(PreferKey.eyeProtectionStartTime) {
+        onRefresh()
+        scheduler.schedule()
+    }
+    observeEvent<String>(PreferKey.eyeProtectionEndTime) {
+        onRefresh()
+        scheduler.schedule()
+    }
+}

+ 53 - 0
app/src/main/java/io/legado/app/ui/book/read/EyeProtectionOverlay.kt

@@ -0,0 +1,53 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+
+/**
+ * 护眼色温覆盖层
+ * 在父 View 之上叠加一层半透明暖色,模拟护眼色温
+ */
+class EyeProtectionOverlay @JvmOverloads constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+    private val paint = Paint().apply {
+        style = Paint.Style.FILL
+    }
+
+    init {
+        isClickable = false
+        isFocusable = false
+        setWillNotDraw(false)
+    }
+
+    /**
+     * 刷新当前护眼状态
+     */
+    fun refresh() {
+        val color = EyeProtectionHelper.overlayColor()
+        if (color == Color.TRANSPARENT) {
+            if (visibility != GONE) {
+                visibility = GONE
+            }
+        } else {
+            if (visibility != VISIBLE) {
+                visibility = VISIBLE
+            }
+            paint.color = color
+            invalidate()
+        }
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+        if (paint.color == Color.TRANSPARENT) return
+        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
+    }
+}

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -278,6 +278,9 @@ class ReadBookActivity : BaseReadBookActivity(),
     private var pageChanged = false
     private val handler by lazy { buildMainHandler() }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
+    private val eyeProtectionScheduler by lazy {
+        EyeProtectionRefreshScheduler(handler) { binding.eyeProtectionOverlay.refresh() }
+    }
     private val executor = ReadBook.executor
     private val upSeekBarThrottle = throttle(200) {
         runOnUiThread {
@@ -302,6 +305,7 @@ class ReadBookActivity : BaseReadBookActivity(),
         ReadBook.register(this)
         binding.cursorLeft.setOnTouchListener(this)
         binding.cursorRight.setOnTouchListener(this)
+        binding.eyeProtectionOverlay.refresh()
 
         onBackPressedDispatcher.addCallback(this) {
             if (isShowingSearchResult) {
@@ -400,6 +404,8 @@ class ReadBookActivity : BaseReadBookActivity(),
         registerReceiver(timeBatteryReceiver, timeBatteryReceiver.filter)
         binding.readView.upTime()
         screenOffTimerStart()
+        binding.eyeProtectionOverlay.refresh()
+        eyeProtectionScheduler.schedule()
         // 网络监听,当从无网切换到网络环境时同步进度(注意注册的同时就会收到监听,因此界面激活时无需重复执行同步操作)
         networkChangedListener.register()
         networkChangedListener.onNetworkChanged = {
@@ -413,6 +419,7 @@ class ReadBookActivity : BaseReadBookActivity(),
     override fun onPause() {
         super.onPause()
         autoPageStop()
+        eyeProtectionScheduler.cancel()
         backupJob?.cancel()
         ReadBook.saveRead()
         ReadBook.stopAutoSaveSession()
@@ -1985,6 +1992,10 @@ class ReadBookActivity : BaseReadBookActivity(),
                 viewModel.refreshContentDur(it)
             }
         }
+        observeEyeProtectionEvents(
+            onRefresh = { binding.eyeProtectionOverlay.refresh() },
+            scheduler = eyeProtectionScheduler
+        )
     }
 
     private fun upScreenTimeOut() {

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -51,6 +51,9 @@ data class SearchUiState(
     val expandedSourceEnd: Boolean = false,
     val expandedSourceError: String? = null,
     val expandedSourcePage: Int = 1,
+    val showExpandedSource: Boolean = false,
+    val expandedSourceSavedScrollIndex: Int = 0,
+    val expandedSourceSavedScrollOffset: Int = 0,
 )
 
 data class SearchEmptyScopeAction(
@@ -90,6 +93,7 @@ sealed interface SearchIntent {
     data object DismissEmptyScopeAction : SearchIntent
     data object OpenSourceManage : SearchIntent
     data class SaveScrollState(val index: Int, val offset: Int) : SearchIntent
+    data class SaveExpandedSourceScrollState(val index: Int, val offset: Int) : SearchIntent
 }
 
 sealed interface SearchEffect {

+ 27 - 2
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -698,14 +698,19 @@ fun SearchScreen(
     )
 
     ExpandedSourceSheet(
-        show = state.expandedSourceUrl != null,
+        show = state.showExpandedSource,
         sourceName = state.expandedSourceName ?: "",
         books = state.expandedSourceBooks,
         isLoading = state.expandedSourceLoading,
         isEnd = state.expandedSourceEnd,
         errorMsg = state.expandedSourceError,
+        savedScrollIndex = state.expandedSourceSavedScrollIndex,
+        savedScrollOffset = state.expandedSourceSavedScrollOffset,
         onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
         onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
+        onSaveScrollState = { index, offset ->
+            viewModel.onIntent(SearchIntent.SaveExpandedSourceScrollState(index, offset))
+        },
         onBookClick = { book, coverKey ->
             viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
         },
@@ -888,8 +893,11 @@ private fun ExpandedSourceSheet(
     isLoading: Boolean,
     isEnd: Boolean,
     errorMsg: String?,
+    savedScrollIndex: Int,
+    savedScrollOffset: Int,
     onDismiss: () -> Unit,
     onLoadMore: () -> Unit,
+    onSaveScrollState: (Int, Int) -> Unit,
     onBookClick: (SearchBook, String?) -> Unit,
     onBookLongClick: ((SearchBook, String?) -> Unit)? = null,
 ) {
@@ -900,7 +908,7 @@ private fun ExpandedSourceSheet(
     ) {
         val listState = rememberLazyListState()
         val showLoadMoreFooter = isLoading || errorMsg != null || isEnd
-
+        val currentOnSaveScrollState by rememberUpdatedState(onSaveScrollState)
         val shouldLoadMore by remember {
             derivedStateOf {
                 val total = listState.layoutInfo.totalItemsCount
@@ -915,6 +923,23 @@ private fun ExpandedSourceSheet(
             }
         }
 
+        LaunchedEffect(savedScrollIndex, savedScrollOffset) {
+            if (savedScrollIndex > 0 || savedScrollOffset > 0) {
+                listState.scrollToItem(savedScrollIndex, savedScrollOffset)
+                onSaveScrollState(0, 0)
+            }
+        }
+
+        DisposableEffect(listState) {
+            onDispose {
+                val first = listState.firstVisibleItemIndex
+                val offset = listState.firstVisibleItemScrollOffset
+                if (first > 0 || offset > 0) {
+                    currentOnSaveScrollState(first, offset)
+                }
+            }
+        }
+
         LazyColumn(
             state = listState,
             modifier = Modifier.fillMaxSize(),

+ 38 - 20
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -225,6 +225,11 @@ class SearchViewModel(
             SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
 
             is SearchIntent.ExpandSource -> {
+                val state = _uiState.value
+                if (state.expandedSourceUrl == intent.sourceUrl) {
+                    _uiState.update { it.copy(showExpandedSource = true) }
+                    return
+                }
                 _uiState.update {
                     it.copy(
                         expandedSourceUrl = intent.sourceUrl,
@@ -234,6 +239,9 @@ class SearchViewModel(
                         expandedSourceEnd = false,
                         expandedSourceError = null,
                         expandedSourcePage = 1,
+                        showExpandedSource = true,
+                        expandedSourceSavedScrollIndex = 0,
+                        expandedSourceSavedScrollOffset = 0,
                     )
                 }
                 loadExpandedSourcePage(intent.sourceUrl, page = 1)
@@ -241,15 +249,7 @@ class SearchViewModel(
 
             SearchIntent.DismissExpandedSource -> {
                 _uiState.update {
-                    it.copy(
-                        expandedSourceUrl = null,
-                        expandedSourceName = null,
-                        expandedSourceBooks = persistentListOf(),
-                        expandedSourceLoading = false,
-                        expandedSourceEnd = false,
-                        expandedSourceError = null,
-                        expandedSourcePage = 1,
-                    )
+                    it.copy(showExpandedSource = false)
                 }
             }
 
@@ -267,17 +267,6 @@ class SearchViewModel(
             }
 
             is SearchIntent.OpenExpandedSourceBook -> {
-                _uiState.update {
-                    it.copy(
-                        expandedSourceUrl = null,
-                        expandedSourceName = null,
-                        expandedSourceBooks = persistentListOf(),
-                        expandedSourceLoading = false,
-                        expandedSourceEnd = false,
-                        expandedSourceError = null,
-                        expandedSourcePage = 1,
-                    )
-                }
                 emitEffect(
                     SearchEffect.OpenBookInfo(
                         name = intent.book.name,
@@ -298,6 +287,15 @@ class SearchViewModel(
                     )
                 }
             }
+
+            is SearchIntent.SaveExpandedSourceScrollState -> {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceSavedScrollIndex = intent.index,
+                        expandedSourceSavedScrollOffset = intent.offset,
+                    )
+                }
+            }
         }
     }
 
@@ -426,6 +424,16 @@ class SearchViewModel(
                 processedSources = 0,
                 totalSources = 0,
                 emptyScopeAction = null,
+                expandedSourceUrl = null,
+                expandedSourceName = null,
+                expandedSourceBooks = persistentListOf(),
+                expandedSourceLoading = false,
+                expandedSourceEnd = false,
+                expandedSourceError = null,
+                expandedSourcePage = 1,
+                showExpandedSource = false,
+                expandedSourceSavedScrollIndex = 0,
+                expandedSourceSavedScrollOffset = 0,
             )
         }
 
@@ -550,6 +558,16 @@ class SearchViewModel(
                 hasMore = true,
                 showSuggestions = true,
                 emptyScopeAction = null,
+                expandedSourceUrl = null,
+                expandedSourceName = null,
+                expandedSourceBooks = persistentListOf(),
+                expandedSourceLoading = false,
+                expandedSourceEnd = false,
+                expandedSourceError = null,
+                expandedSourcePage = 1,
+                showExpandedSource = false,
+                expandedSourceSavedScrollIndex = 0,
+                expandedSourceSavedScrollOffset = 0,
             )
         }
     }

+ 6 - 1
app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt

@@ -27,7 +27,8 @@ fun ConfigNavScreen(
     onNavigateToTheme: () -> Unit,
     onNavigateToBackup: () -> Unit,
     onNavigateToDownloadCache: () -> Unit,
-    onNavigateToTranslation: () -> Unit
+    onNavigateToTranslation: () -> Unit,
+    onNavigateToLab: () -> Unit
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
@@ -80,6 +81,10 @@ fun ConfigNavScreen(
                         title = stringResource(R.string.translation_config),
                         onClick = onNavigateToTranslation
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.lab_setting),
+                        onClick = onNavigateToLab
+                    )
                 }
             }
         }

+ 23 - 0
app/src/main/java/io/legado/app/ui/config/labConfig/LabConfig.kt

@@ -0,0 +1,23 @@
+package io.legado.app.ui.config.labConfig
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefDelegate
+
+object LabConfig {
+
+    var labEnabled by prefDelegate(
+        PreferKey.labEnabled,
+        false
+    )
+
+    var eInkDisplay by prefDelegate(
+        PreferKey.labEInkDisplay,
+        false
+    )
+
+    var eyeProtection by prefDelegate(
+        PreferKey.labEyeProtection,
+        false
+    )
+
+}

+ 108 - 0
app/src/main/java/io/legado/app/ui/config/labConfig/LabConfigScreen.kt

@@ -0,0 +1,108 @@
+package io.legado.app.ui.config.labConfig
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LabConfigScreen(
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.lab_setting),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.lab_enabled_title),
+                        description = stringResource(R.string.lab_enabled_summary),
+                        checked = LabConfig.labEnabled,
+                        onCheckedChange = { LabConfig.labEnabled = it }
+                    )
+                }
+
+                AnimatedVisibility(visible = LabConfig.labEnabled) {
+                    SplicedColumnGroup(title = stringResource(R.string.lab_display)) {
+                        SwitchSettingItem(
+                            title = stringResource(R.string.lab_eink_display_title),
+                            description = stringResource(R.string.lab_eink_display_summary),
+                            checked = LabConfig.eInkDisplay,
+                            onCheckedChange = {
+                                LabConfig.eInkDisplay = it
+                            }
+                        )
+
+                        if (LabConfig.eInkDisplay) {
+                            Text(
+                                text = stringResource(R.string.lab_eink_display_hint),
+                                style = LegadoTheme.typography.labelSmall,
+                                color = MaterialTheme.colorScheme.primary,
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 4.dp)
+                            )
+                        }
+
+                        SwitchSettingItem(
+                            title = stringResource(R.string.lab_eye_protection_title),
+                            description = stringResource(R.string.lab_eye_protection_summary),
+                            checked = LabConfig.eyeProtection,
+                            onCheckedChange = {
+                                LabConfig.eyeProtection = it
+                            }
+                        )
+
+                        if (LabConfig.eyeProtection) {
+                            Text(
+                                text = stringResource(R.string.lab_eye_protection_hint),
+                                style = LegadoTheme.typography.labelSmall,
+                                color = MaterialTheme.colorScheme.primary,
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 4.dp)
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 17 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -182,6 +182,23 @@ object ThemeConfig {
 
     var navIconMy by prefDelegate(PreferKey.navIconMy, "")
 
+    // Eye Protection
+    var eyeProtectionEnabled by prefDelegate(PreferKey.eyeProtectionEnabled, false) {
+        postEvent(PreferKey.eyeProtectionEnabled, it)
+    }
+    var colorTemperature by prefDelegate(PreferKey.colorTemperature, 50) {
+        postEvent(PreferKey.colorTemperature, it)
+    }
+    var eyeProtectionSchedule by prefDelegate(PreferKey.eyeProtectionSchedule, false) {
+        postEvent(PreferKey.eyeProtectionSchedule, it)
+    }
+    var eyeProtectionStartTime by prefDelegate(PreferKey.eyeProtectionStartTime, "22:00") {
+        postEvent(PreferKey.eyeProtectionStartTime, it)
+    }
+    var eyeProtectionEndTime by prefDelegate(PreferKey.eyeProtectionEndTime, "07:00") {
+        postEvent(PreferKey.eyeProtectionEndTime, it)
+    }
+
     fun hasImageBg(isDark: Boolean): Boolean =
         !(if (isDark) bgImageDark else bgImageLight).isNullOrBlank()
 

+ 119 - 1
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -55,6 +55,7 @@ import androidx.compose.material3.ToggleButtonDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
@@ -84,6 +85,7 @@ import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.OldThemeConfig
 import io.legado.app.help.loadFontFiles
+import io.legado.app.ui.config.labConfig.LabConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -296,9 +298,12 @@ fun ThemeConfigScreen(
                     if (!isMiuixEngine) {
                         Spacer(modifier = Modifier.height(16.dp))
 
+                        val visibleThemes = themes.filter { (_, value) ->
+                            value != "4" || (LabConfig.labEnabled && LabConfig.eInkDisplay)
+                        }
                         ThemeColorSelector(
                             context = context,
-                            themes = themes,
+                            themes = visibleThemes,
                             selectedTheme = selectedTheme,
                             isDark = isDarkTheme,
                             isAmoled = ThemeConfig.isPureBlack,
@@ -464,6 +469,119 @@ fun ThemeConfigScreen(
                     )
                 }
 
+                SplicedColumnGroup(title = stringResource(R.string.eye_protection)) {
+                    var eyeProtectionEnabled by remember {
+                        mutableStateOf(ThemeConfig.eyeProtectionEnabled)
+                    }
+                    var colorTemperature by remember {
+                        mutableIntStateOf(ThemeConfig.colorTemperature)
+                    }
+                    var eyeProtectionSchedule by remember {
+                        mutableStateOf(ThemeConfig.eyeProtectionSchedule)
+                    }
+                    var eyeProtectionStartTime by remember {
+                        mutableStateOf(ThemeConfig.eyeProtectionStartTime)
+                    }
+                    var eyeProtectionEndTime by remember {
+                        mutableStateOf(ThemeConfig.eyeProtectionEndTime)
+                    }
+
+                    SwitchSettingItem(
+                        title = stringResource(R.string.eye_protection_enabled),
+                        description = stringResource(R.string.eye_protection_enabled_summary),
+                        checked = eyeProtectionEnabled,
+                        onCheckedChange = {
+                            eyeProtectionEnabled = it
+                            ThemeConfig.eyeProtectionEnabled = it
+                        }
+                    )
+
+                    AnimatedVisibility(visible = eyeProtectionEnabled) {
+                        Column {
+                            SliderSettingItem(
+                                title = stringResource(R.string.color_temperature),
+                                description = stringResource(
+                                    R.string.color_temperature_summary,
+                                    colorTemperature
+                                ),
+                                value = colorTemperature.toFloat(),
+                                defaultValue = 50f,
+                                valueRange = 0f..100f,
+                                steps = 99,
+                                onValueChange = {
+                                    colorTemperature = it.toInt()
+                                    ThemeConfig.colorTemperature = it.toInt()
+                                }
+                            )
+
+                            SwitchSettingItem(
+                                title = stringResource(R.string.eye_protection_schedule),
+                                description = stringResource(R.string.eye_protection_schedule_summary),
+                                checked = eyeProtectionSchedule,
+                                onCheckedChange = {
+                                    eyeProtectionSchedule = it
+                                    ThemeConfig.eyeProtectionSchedule = it
+                                }
+                            )
+
+                            AnimatedVisibility(visible = eyeProtectionSchedule) {
+                                Column {
+                                    ClickableSettingItem(
+                                        title = stringResource(R.string.eye_protection_start_time),
+                                        option = eyeProtectionStartTime,
+                                        onClick = {
+                                            val parts = eyeProtectionStartTime.split(":")
+                                            val hour = parts.getOrNull(0)?.toIntOrNull() ?: 22
+                                            val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
+                                            android.app.TimePickerDialog(
+                                                context,
+                                                { _, h, m ->
+                                                    val timeStr = String.format(
+                                                        java.util.Locale.US,
+                                                        "%02d:%02d",
+                                                        h.coerceIn(0, 23),
+                                                        m.coerceIn(0, 59)
+                                                    )
+                                                    eyeProtectionStartTime = timeStr
+                                                    ThemeConfig.eyeProtectionStartTime = timeStr
+                                                },
+                                                hour.coerceIn(0, 23),
+                                                minute.coerceIn(0, 59),
+                                                true
+                                            ).show()
+                                        }
+                                    )
+                                    ClickableSettingItem(
+                                        title = stringResource(R.string.eye_protection_end_time),
+                                        option = eyeProtectionEndTime,
+                                        onClick = {
+                                            val parts = eyeProtectionEndTime.split(":")
+                                            val hour = parts.getOrNull(0)?.toIntOrNull() ?: 7
+                                            val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
+                                            android.app.TimePickerDialog(
+                                                context,
+                                                { _, h, m ->
+                                                    val timeStr = String.format(
+                                                        java.util.Locale.US,
+                                                        "%02d:%02d",
+                                                        h.coerceIn(0, 23),
+                                                        m.coerceIn(0, 59)
+                                                    )
+                                                    eyeProtectionEndTime = timeStr
+                                                    ThemeConfig.eyeProtectionEndTime = timeStr
+                                                },
+                                                hour.coerceIn(0, 23),
+                                                minute.coerceIn(0, 59),
+                                                true
+                                            ).show()
+                                        }
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+
                 SplicedColumnGroup(title = stringResource(R.string.compose_related)) {
                     if (!isMiuixEngine) {
                         SwitchSettingItem(

+ 7 - 1
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -42,6 +42,7 @@ import io.legado.app.ui.config.otherConfig.OtherConfigScreen
 import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
 import io.legado.app.ui.config.themeManage.ThemeManageScreen
+import io.legado.app.ui.config.labConfig.LabConfigScreen
 import io.legado.app.ui.config.translation.TranslationConfigScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
@@ -159,7 +160,8 @@ fun MainActivity.mainEntryProvider(
             onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
             onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) },
             onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) },
-            onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
+            onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) },
+            onNavigateToLab = { backStack.add(MainRouteSettingsLabConfig) }
         )
     }
 
@@ -195,6 +197,10 @@ fun MainActivity.mainEntryProvider(
         TranslationConfigScreen(onBackClick = { onNavigateBack() })
     }
 
+    entry<MainRouteSettingsLabConfig> {
+        LabConfigScreen(onBackClick = { onNavigateBack() })
+    }
+
     entry<MainRouteSettingsCustomTheme> {
         CustomThemeScreen(
             onBackClick = { onNavigateBack() }

+ 4 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -33,6 +33,9 @@ data object MainRouteSettingsCustomTheme : MainRoute
 @Serializable
 data object MainRouteSettingsThemeManage : MainRoute
 
+@Serializable
+data object MainRouteSettingsLabConfig : MainRoute
+
 @Serializable
 data object MainRouteSettingsDownloadCache : MainRoute
 
@@ -98,6 +101,7 @@ object MainRouteConst {
     const val ROUTE_SETTINGS_THEME = "settings/theme"
     const val ROUTE_SETTINGS_BACKUP = "settings/backup"
     const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
+    const val ROUTE_SETTINGS_LAB_CONFIG = "settings/lab_config"
     const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache"
     const val ROUTE_SETTINGS_TRANSLATION = "settings/translation"
     const val ROUTE_IMPORT_LOCAL = "import/local"

+ 8 - 0
app/src/main/res/layout/activity_book_read.xml

@@ -50,4 +50,12 @@
         android:layout_height="0dp"
         android:layout_gravity="bottom" />
 
+    <io.legado.app.ui.book.read.EyeProtectionOverlay
+        android:id="@+id/eye_protection_overlay"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clickable="false"
+        android:focusable="false"
+        android:visibility="gone" />
+
 </FrameLayout>

+ 8 - 1
app/src/main/res/layout/activity_manga.xml

@@ -41,7 +41,6 @@
         android:id="@+id/fl_loading"
         android:layout_width="match_parent"
         android:layout_height="match_parent">
-
         <LinearLayout
             android:id="@+id/ll_loading"
             android:layout_width="match_parent"
@@ -94,4 +93,12 @@
 
     </FrameLayout>
 
+    <io.legado.app.ui.book.read.EyeProtectionOverlay
+        android:id="@+id/eye_protection_overlay"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clickable="false"
+        android:focusable="false"
+        android:visibility="gone" />
+
 </FrameLayout>

+ 23 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1786,4 +1786,27 @@
     <string name="return_to_original">返回原文</string>
     <string name="translation_progress">翻译中 %1$d%%</string>
     <string name="translation_started">开始进行翻译,请耐心等待……</string>
+
+    <!-- 实验室 -->
+    <string name="lab_setting">实验室</string>
+    <string name="lab_enabled_title">启用实验室</string>
+    <string name="lab_enabled_summary">开启后显示实验性功能,可能不稳定</string>
+    <string name="lab_display">显示</string>
+    <string name="lab_eink_display_title">墨水屏显示</string>
+    <string name="lab_eink_display_summary">在平板界面显示墨水屏选项</string>
+    <string name="lab_eink_display_hint">可在 主题 → 显示 中调整墨水屏设置</string>
+    <string name="lab_eye_protection_title">护眼模式</string>
+    <string name="lab_eye_protection_summary">为漫画和小说阅读器叠加暖色滤镜,减少蓝光</string>
+    <string name="lab_eye_protection_hint">可在 主题 → 外观 中调整护眼模式</string>
+
+    <!-- 护眼模式 -->
+    <string name="eye_protection">护眼模式</string>
+    <string name="eye_protection_enabled">启用护眼模式</string>
+    <string name="eye_protection_enabled_summary">使用暖色调降低蓝光</string>
+    <string name="color_temperature">屏幕色温</string>
+    <string name="color_temperature_summary">当前色温:%1$d</string>
+    <string name="eye_protection_schedule">按时间段启用</string>
+    <string name="eye_protection_schedule_summary">仅在配置的时间段内生效</string>
+    <string name="eye_protection_start_time">开始时间</string>
+    <string name="eye_protection_end_time">结束时间</string>
 </resources>

+ 23 - 0
app/src/main/res/values/strings.xml

@@ -1792,4 +1792,27 @@
     <string name="homepage_status_in_effect"> · In effect</string>
     <string name="homepage_status_blocked"> · Blocked</string>
     <string name="homepage_status_joined"> · Already joined</string>
+
+    <!-- Lab -->
+    <string name="lab_setting">Lab</string>
+    <string name="lab_enabled_title">Enable Lab</string>
+    <string name="lab_enabled_summary">Turn on to show experimental features. These may be unstable.</string>
+    <string name="lab_display">Display</string>
+    <string name="lab_eink_display_title">E-Ink Display</string>
+    <string name="lab_eink_display_summary">Show E-Ink display option in the tablet interface</string>
+    <string name="lab_eink_display_hint">You can adjust E-Ink display settings in Theme → Display options</string>
+    <string name="lab_eye_protection_title">Eye Protection</string>
+    <string name="lab_eye_protection_summary">Apply warm color tint to comic and novel readers to reduce eye strain</string>
+    <string name="lab_eye_protection_hint">You can adjust eye protection settings in Theme → Appearance</string>
+
+    <!-- Eye Protection -->
+    <string name="eye_protection">Eye Protection</string>
+    <string name="eye_protection_enabled">Enable Eye Protection</string>
+    <string name="eye_protection_enabled_summary">Reduce blue light via warm color tint</string>
+    <string name="color_temperature">Color Temperature</string>
+    <string name="color_temperature_summary">Current temperature: %1$d</string>
+    <string name="eye_protection_schedule">Scheduled</string>
+    <string name="eye_protection_schedule_summary">Only apply during the configured time range</string>
+    <string name="eye_protection_start_time">Start Time</string>
+    <string name="eye_protection_end_time">End Time</string>
 </resources>

+ 2 - 15
modules/web/src/store/bookStore.ts

@@ -47,7 +47,7 @@ export const useBookStore = defineStore('book', {
   },
   getters: {
     bookProgress: (state): BookProgress | undefined => {
-      if (state.catalog.length == 0) return
+      if (state.catalog.length === 0) return
       const { chapterIndex, chapterPos, name, author } = state.readingBook
       const title = state.catalog[chapterIndex]?.title
       if (!title) return
@@ -63,13 +63,12 @@ export const useBookStore = defineStore('book', {
     theme: state => {
       return state.config.theme
     },
-    isNight: state => state.config.theme == 6,
+    isNight: state => state.config.theme === 6,
   },
   actions: {
     /** 从后端加载书架书籍,优先返回内存缓存 */
     async loadBookShelf(): Promise<Book[]> {
       const fetchBookshellf_promise = API.getBookShelf().then(resp => {
-        console.log('API.getBookShelf数据返回')
         const { isSuccess, data, errorMsg } = resp.data
         if (isSuccess === true) {
           if (
@@ -91,16 +90,13 @@ export const useBookStore = defineStore('book', {
           }
           ElMessage.error(errorMsg ?? '后端返回格式错误!')
         }
-        console.log('书架数据已更新')
         return this.shelf
       })
 
       if (this.shelf.length > 0) {
         // bookshelf data fetched before:do not await
-        console.log('返回缓存书架数据')
         return this.shelf
       } else {
-        console.log('从阅读后端获取书架数据...')
         return await fetchBookshellf_promise
       }
     },
@@ -126,7 +122,6 @@ export const useBookStore = defineStore('book', {
           ElMessage.info(`书籍${name}: 章节目录已更新`)
         }
         this.catalog = data
-        console.log(`书籍${name}: 章节目录已更新`)
         return this.catalog
       })
       if (
@@ -134,10 +129,8 @@ export const useBookStore = defineStore('book', {
         this.catalog.length > 0 &&
         this.catalog.length - 1 >= chapterIndex
       ) {
-        console.log(`返回书籍《${name}》 缓存的章节目录`)
         return this.catalog
       } else {
-        console.log(`从阅读后端获取书籍《${name}》 章节目录数据...`)
         return await fetchChapterList_promise
       }
     },
@@ -155,14 +148,8 @@ export const useBookStore = defineStore('book', {
       if (webReadConfigLoadedDate === undefined) {
         const _config = await API.getReadConfig()
         webReadConfigLoadedDate = new Date()
-        console.log(
-          `${this.$id}.loadWebConfig: ${webReadConfigLoadedDate.toLocaleString()}成功加载阅读配置`,
-        )
         return this.setConfig(_config)
       }
-      console.log(
-        `${this.$id}.loadWebConfig: 已于${webReadConfigLoadedDate.toLocaleString()}成功加载`,
-      )
     },
     setConfig(config?: webReadConfig) {
       this.config = Object.assign({}, this.config, config)