Epub reader themes (#97)

* Implemented a custom theme engine for the EPUB reader, allowing users to switch between built-in themes (System, Light, Dark, Sepia, Slate, and OLED).

Key changes include:
- Added `ReaderThemePanel` and theme selection logic to `EpubReaderScreen`.
- Introduced a theme persistence layer using SharedPreferences.
- Updated `ChapterWebView` and `epub_reader.js` to dynamically apply background and text colors via JavaScript, including improved contrast adjustment for inline styles.
- Enhanced `CssParser` and `ContentStyler` in the paginated reader to adapt CSS colors based on the active theme's background and text luminance.
- Integrated theme-specific background and text colors into `PaginatedReaderScreen` and its layout components.
- Added a theme palette icon to the reader controls.

* Added custom theme builder and texture support to EPUB reader

* Refactored color picker(PDF) into shared components and enhanced EPUB theme editor

* Updated theme customization UI and improved WebView theme application efficiency

* Updated page flip animation to use paper color with a theme-based tint

* Improved state restoration during paginated reader reconfiguration
This commit is contained in:
Aryan 2026-03-19 19:59:51 +05:30 committed by GitHub
parent eaf0d4af00
commit c9dc3ce8c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1817 additions and 847 deletions

View file

@ -189,7 +189,7 @@
window.VIEWPORT_PADDING_BOTTOM = bottom || 0;
};
window.applyReaderTheme = function (isDark) {
window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) {
var styleId = "readerThemeStyle";
var themeStyleElement = document.getElementById(styleId);
@ -199,65 +199,185 @@
document.head.appendChild(themeStyleElement);
}
// Set a class on the root element for theme state
var themeClassName = isDark ? "dark-theme" : "light-theme";
var oppositeThemeClassName = isDark ? "light-theme" : "dark-theme";
document.documentElement.classList.remove(oppositeThemeClassName);
document.documentElement.classList.add(themeClassName);
var css = "";
var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
if (isDark) {
css = ` html.dark-theme, html.dark-theme body {
background-color: #121212 !important;
color: #E0E0E0 !important;
}
var textureCss = textureBase64
? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;`
: 'background-image: none;';
html.dark-theme a {
color: #BB86FC !important;
}
var css = `
:root {
--reader-bg: ${effectiveBg};
--reader-text: ${effectiveText};
}
html.${themeClassName}, html.${themeClassName} body {
background-color: var(--reader-bg) !important;
color: var(--reader-text) !important;
${textureCss}
}
html.dark-theme a p,
html.dark-theme a div,
html.dark-theme a span,
html.dark-theme a li,
html.dark-theme a h1,
html.dark-theme a h2,
html.dark-theme a h3,
html.dark-theme a h4,
html.dark-theme a h5,
html.dark-theme a h6 {
color: #E0E0E0 !important;
background-color: transparent !important;
}
html.${themeClassName} a {
color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important;
}
html.dark-theme blockquote, html.dark-theme pre,
html.dark-theme figcaption, html.dark-theme caption,
html.dark-theme label, html.dark-theme dt, html.dark-theme dd {
color: inherit !important;
background-color: transparent !important;
}
html.${themeClassName} a p,
html.${themeClassName} a div,
html.${themeClassName} a span,
html.${themeClassName} a li,
html.${themeClassName} a h1,
html.${themeClassName} a h2,
html.${themeClassName} a h3,
html.${themeClassName} a h4,
html.${themeClassName} a h5,
html.${themeClassName} a h6 {
color: var(--reader-text) !important;
background-color: transparent !important;
}
html.dark-theme hr {
border-color: #444444 !important;
background-color: #444444 !important;
}
html.${themeClassName} blockquote, html.${themeClassName} pre,
html.${themeClassName} figcaption, html.${themeClassName} caption,
html.${themeClassName} label, html.${themeClassName} dt, html.${themeClassName} dd {
color: inherit !important;
background-color: transparent !important;
}
html.dark-theme table, html.dark-theme tr, html.dark-theme td, html.dark-theme th {
background-color: transparent !important;
border-color: #555 !important;
}
html.${themeClassName} hr {
border-color: ${isDark ? '#444444' : '#CCCCCC'} !important;
background-color: ${isDark ? '#444444' : '#CCCCCC'} !important;
}
`;
} else {
css = ` html.light-theme {
background-color: #FFFFFF;
}
`;
}
html.${themeClassName} table, html.${themeClassName} tr, html.${themeClassName} td, html.${themeClassName} th {
background-color: transparent !important;
border-color: ${isDark ? '#555' : '#CCC'} !important;
}
`;
themeStyleElement.innerHTML = css;
if (window.adjustInlineColorsForContrast) {
window.adjustInlineColorsForContrast(isDark, effectiveBg);
}
};
function getLuminance(r, g, b) {
var a = [r, g, b].map(function (v) {
v /= 255;
return v <= 0.03928
? v / 12.92
: Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
function hexToRgb(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : {r:255,g:255,b:255};
}
function rgbStringToRgb(rgbStr) {
var parts = rgbStr.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (parts) {
return { r: parseInt(parts[1]), g: parseInt(parts[2]), b: parseInt(parts[3]) };
}
return null;
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if(max == min){
h = s = 0; // achromatic
}else{
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max){
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return[h, s, l];
}
function hslToRgb(h, s, l) {
var r, g, b;
if(s == 0){
r = g = b = l; // achromatic
}else{
var hue2rgb = function hue2rgb(p, q, t){
if(t < 0) t += 1;
if(t > 1) t -= 1;
if(t < 1/6) return p + (q - p) * 6 * t;
if(t < 1/2) return q;
if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
}
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return[Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
window.adjustInlineColorsForContrast = function(isDark, bgHex) {
var bgRgb = hexToRgb(bgHex);
var bgLum = getLuminance(bgRgb.r, bgRgb.g, bgRgb.b);
var elements = document.querySelectorAll('[style*="color"]');
elements.forEach(function(el) {
var style = window.getComputedStyle(el);
var colorStr = style.color;
var rgb = rgbStringToRgb(colorStr);
if (rgb) {
var lum = getLuminance(rgb.r, rgb.g, rgb.b);
var l1 = Math.max(bgLum, lum);
var l2 = Math.min(bgLum, lum);
var contrast = (l1 + 0.05) / (l2 + 0.05);
if (contrast < 4.5) {
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
if (bgLum < 0.5) {
hsl[2] = Math.max(hsl[2], 0.7); // Lighten
} else {
hsl[2] = Math.min(hsl[2], 0.3); // Darken
}
var newRgb = hslToRgb(hsl[0], hsl[1], hsl[2]);
el.style.setProperty('color', `rgb(${newRgb[0]}, ${newRgb[1]}, ${newRgb[2]})`, 'important');
}
}
});
var bgElements = document.querySelectorAll('[style*="background"]');
bgElements.forEach(function(el) {
var style = window.getComputedStyle(el);
var bgStr = style.backgroundColor;
if (bgStr && bgStr !== 'rgba(0, 0, 0, 0)' && bgStr !== 'transparent') {
var rgb = rgbStringToRgb(bgStr);
if (rgb) {
var lum = getLuminance(rgb.r, rgb.g, rgb.b);
if (isDark && lum > 0.5) {
el.style.setProperty('background-color', 'transparent', 'important');
} else if (!isDark && lum < 0.2) {
el.style.setProperty('background-color', 'transparent', 'important');
}
}
}
});
};
function handleHighlightInteraction(e) {

View file

@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -56,6 +60,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@ -103,9 +108,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@ -116,13 +128,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.util.UnstableApi
@ -156,6 +172,8 @@ import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import kotlin.math.roundToInt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize"
@ -1718,3 +1736,282 @@ fun DeviceVoiceSettingsSheet(
}
}
}
@Composable
fun SpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(h, s)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(
brush = Brush.horizontalGradient(rainbowColors)
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
val pointerRadius = 10.dp.toPx()
val strokeWidth = 2.dp.toPx()
drawCircle(
color = Color.Black.copy(alpha = 0.25f),
radius = pointerRadius + 1.dp.toPx(),
center = Offset(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
fun BrightnessSlider(
hue: Float,
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val baseColor = remember(hue, saturation) {
Color.hsv(hue, saturation, 1f)
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(v)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, baseColor)
)
)
val x = value * size.width
drawCircle(
color = Color.White,
radius = 8.dp.toPx(),
center = Offset(x, size.height / 2)
)
}
}
}
@Composable
fun RgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
RgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
fun RgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.toString()) }
LaunchedEffect(value) {
text = value.toString()
}
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
val intVal = newText.toIntOrNull()
if (intVal != null) {
onValueChange(intVal.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
fun HexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = remember(color) {
String.format("%06X", (0xFFFFFF and color.toArgb()))
}
var text by remember(hexValue) { mutableStateOf(hexValue) }
LaunchedEffect(color) {
val currentParsed = try {
Color(("#$text").toColorInt())
} catch (_: Exception) {
null
}
if (currentParsed?.toArgb() != color.toArgb()) {
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
try {
val parsedColorInt = "#$uppercased".toColorInt()
val newColor = Color(parsedColorInt)
onHexChanged(newColor)
} catch (_: Exception) {
}
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
fun ColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
}

View file

@ -152,6 +152,7 @@ fun EpubReaderTopBar(
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
@ -209,6 +210,13 @@ fun EpubReaderTopBar(
contentDescription = "Dictionary Settings"
)
}
TooltipIconButton(
text = "Theme",
description = "Theme Settings",
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = "Theme Settings")
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
TooltipIconButton(

View file

@ -29,11 +29,13 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import com.aryan.reader.SpectrumBox
import com.aryan.reader.BrightnessSlider
import com.aryan.reader.ColorComparePill
import com.aryan.reader.HexInput
import com.aryan.reader.RgbInputColumn
import android.graphics.Bitmap
import android.media.AudioManager
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import android.net.Uri
import android.os.Build
import android.webkit.WebView
@ -51,14 +53,20 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
@ -71,26 +79,35 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
@ -116,19 +133,32 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.WindowCompat
@ -143,6 +173,7 @@ import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
@ -335,6 +366,94 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
enum class ReaderTexture(val id: String, val resId: Int, val displayName: String) {
PAPER("paper", R.drawable.texture_paper, "Paper"),
CANVAS("canvas", R.drawable.texture_canvas, "Canvas"),
EINK("eink", R.drawable.texture_eink, "E-Ink"),
SLATE("slate", R.drawable.texture_slate, "Slate")
}
data class ReaderTheme(
val id: String,
val name: String,
val backgroundColor: Color,
val textColor: Color,
val isDark: Boolean,
val textureId: String? = null,
val isCustom: Boolean = false
)
val BuiltInThemes = listOf(
ReaderTheme("system", "System", Color.Unspecified, Color.Unspecified, false),
ReaderTheme("light", "Light", Color(0xFFFFFFFF), Color(0xFF000000), false),
ReaderTheme("dark", "Dark", Color(0xFF121212), Color(0xFFE0E0E0), true),
ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false),
ReaderTheme("slate", "Slate", Color(0xFF2E3440), Color(0xFFECEFF4), true),
ReaderTheme("oled", "OLED", Color(0xFF000000), Color(0xFFB0B0B0), true)
)
private const val PREF_READER_THEME = "reader_theme_id"
private const val PREF_CUSTOM_THEMES = "custom_themes_json"
private fun saveReaderThemeId(context: Context, themeId: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_READER_THEME, themeId) }
}
private fun loadReaderThemeId(context: Context): String {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_READER_THEME, "system") ?: "system"
}
private fun saveCustomThemes(context: Context, themes: List<ReaderTheme>) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonArray = JSONArray()
themes.filter { it.isCustom }.forEach { theme ->
val obj = JSONObject().apply {
put("id", theme.id)
put("name", theme.name)
put("bgColor", theme.backgroundColor.toArgb())
put("textColor", theme.textColor.toArgb())
put("isDark", theme.isDark)
theme.textureId?.let { put("textureId", it) }
}
jsonArray.put(obj)
}
prefs.edit { putString(PREF_CUSTOM_THEMES, jsonArray.toString()) }
}
private fun loadCustomThemes(context: Context): List<ReaderTheme> {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]"
val themes = mutableListOf<ReaderTheme>()
try {
val jsonArray = org.json.JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
themes.add(
ReaderTheme(
id = obj.getString("id"),
name = obj.getString("name"),
backgroundColor = Color(obj.getInt("bgColor")),
textColor = Color(obj.getInt("textColor")),
isDark = obj.getBoolean("isDark"),
textureId = if (obj.has("textureId")) obj.getString("textureId") else null,
isCustom = true
)
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse custom themes")
}
return themes
}
private fun calculateContrastRatio(color1: Color, color2: Color): Float {
val l1 = max(color1.luminance(), color2.luminance())
val l2 = min(color1.luminance(), color2.luminance())
return (l1 + 0.05f) / (l2 + 0.05f)
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -957,9 +1076,33 @@ fun EpubReaderHost(
}
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
val activeTheme = remember(currentThemeId, customThemes) {
BuiltInThemes.find { it.id == currentThemeId }
?: customThemes.find { it.id == currentThemeId }
?: BuiltInThemes[0]
}
val systemIsDark = isSystemInDarkTheme()
val isDarkTheme = if (activeTheme.id == "system") systemIsDark else activeTheme.isDark
val effectiveBg = remember(activeTheme, systemIsDark) {
if (activeTheme.id == "system") {
if (systemIsDark) Color(0xFF121212) else Color(0xFFFFFFFF)
} else activeTheme.backgroundColor
}
val effectiveText = remember(activeTheme, systemIsDark) {
if (activeTheme.id == "system") {
if (systemIsDark) Color(0xFFE0E0E0) else Color(0xFF000000)
} else activeTheme.textColor
}
val activeTextureId = activeTheme.textureId
val currentChapterInPaginatedMode by remember {
derivedStateOf {
@ -1892,6 +2035,7 @@ fun EpubReaderHost(
Box(
modifier = Modifier
.fillMaxSize()
.background(effectiveBg)
.padding(scaffoldPaddingValues)
.focusRequester(containerFocusRequester)
.focusable()
@ -2080,6 +2224,8 @@ fun EpubReaderHost(
key = chapterKeyForWebView,
chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
initialScrollTarget = initialScrollTargetForChapter,
initialPageScrollY = currentScrollYPosition,
initialCfi = cfiToLoad,
@ -2276,6 +2422,7 @@ fun EpubReaderHost(
currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis()
showBars = false
@ -2622,6 +2769,8 @@ fun EpubReaderHost(
PaginatedReaderScreen(
book = epubBook,
isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
pagerState = paginatedPagerState,
searchQuery = searchState.searchQuery,
fontSizeMultiplier = currentFontSizeEm,
@ -2636,6 +2785,7 @@ fun EpubReaderHost(
cfi = ttsState.sourceCfi ?: "",
offset = ttsState.startOffsetInSource
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
activeTextureId = activeTextureId,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
onPaginatorReady = { newPaginator ->
@ -3305,6 +3455,7 @@ fun EpubReaderHost(
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onToggleReflow = if (onToggleReflow != null) {
{
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -3876,5 +4027,516 @@ fun EpubReaderHost(
Spacer(Modifier.height(16.dp))
}
}
if (showThemePanel) {
ReaderThemePanel(
isVisible = true,
currentThemeId = currentThemeId,
onThemeSelected = {
currentThemeId = it
saveReaderThemeId(context, it)
showThemePanel = false
},
onDismiss = { showThemePanel = false },
customThemes = customThemes,
onCustomThemesUpdated = { customThemes = it; saveCustomThemes(context, it) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderThemePanel(
isVisible: Boolean,
currentThemeId: String,
customThemes: List<ReaderTheme>,
onThemeSelected: (String) -> Unit,
onCustomThemesUpdated: (List<ReaderTheme>) -> Unit,
onDismiss: () -> Unit
) {
if (!isVisible) return
var showBuilder by remember { mutableStateOf(false) }
var editingTheme by remember { mutableStateOf<ReaderTheme?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
contentWindowInsets = { WindowInsets.navigationBars }
) {
AnimatedContent(targetState = showBuilder, label = "ThemePanelTransition") { isBuilding ->
if (isBuilding) {
ThemeBuilderView(
initialTheme = editingTheme,
onSave = { newTheme ->
val updatedList = if (editingTheme != null) {
customThemes.map { if (it.id == newTheme.id) newTheme else it }
} else {
customThemes + newTheme
}
onCustomThemesUpdated(updatedList)
onThemeSelected(newTheme.id)
showBuilder = false
editingTheme = null
},
onCancel = {
showBuilder = false
editingTheme = null
}
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.65f)
.padding(16.dp)
.padding(bottom = 16.dp)
) {
Text(
"Reading Themes",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
Text("Presets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(8.dp))
ThemeGrid(themes = BuiltInThemes, currentThemeId = currentThemeId, onThemeSelected = onThemeSelected)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("My Themes", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
IconButton(onClick = { editingTheme = null; showBuilder = true }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Default.Add, contentDescription = "Create Theme", tint = MaterialTheme.colorScheme.primary)
}
}
Spacer(Modifier.height(8.dp))
if (customThemes.isEmpty()) {
Text("No custom themes yet. Tap '+' to create one.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
ThemeGrid(
themes = customThemes,
currentThemeId = currentThemeId,
onThemeSelected = onThemeSelected,
onEdit = { editingTheme = it; showBuilder = true },
onDelete = { themeToDelete ->
val updated = customThemes.filter { it.id != themeToDelete.id }
onCustomThemesUpdated(updated)
if (currentThemeId == themeToDelete.id) onThemeSelected("system")
}
)
}
}
}
}
}
}
@Composable
fun ThemeGrid(
themes: List<ReaderTheme>,
currentThemeId: String,
onThemeSelected: (String) -> Unit,
onEdit: ((ReaderTheme) -> Unit)? = null,
onDelete: ((ReaderTheme) -> Unit)? = null
) {
androidx.compose.foundation.lazy.grid.LazyVerticalGrid(
columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(themes.size) { index ->
val theme = themes[index]
val isSelected = currentThemeId == theme.id
val bgColor = if (theme.id == "system") MaterialTheme.colorScheme.surfaceVariant else theme.backgroundColor
val textColor = if (theme.id == "system") MaterialTheme.colorScheme.onSurfaceVariant else theme.textColor
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(56.dp)
.background(bgColor, CircleShape)
.border(if (isSelected) 3.dp else 1.dp, borderColor, CircleShape)
.clickable { onThemeSelected(theme.id) },
contentAlignment = Alignment.Center
) {
Text(text = "Aa", color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (theme.isCustom && onEdit != null && onDelete != null) {
Spacer(modifier = Modifier.height(6.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Edit, "Edit", Modifier.size(28.dp).clip(CircleShape).clickable { onEdit(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(4.dp))
Icon(Icons.Default.Delete, "Delete", Modifier.size(28.dp).clip(CircleShape).clickable { onDelete(theme) }.padding(6.dp), tint = MaterialTheme.colorScheme.error)
}
}
}
}
}
}
}
@Composable
fun ThemeBuilderView(
initialTheme: ReaderTheme?,
onSave: (ReaderTheme) -> Unit,
onCancel: () -> Unit
) {
var name by remember { mutableStateOf(initialTheme?.name ?: "Custom Theme") }
var bgColor by remember { mutableStateOf(initialTheme?.backgroundColor ?: Color(0xFFF5F5F5)) }
var txtColor by remember { mutableStateOf(initialTheme?.textColor ?: Color(0xFF111111)) }
var textureId by remember { mutableStateOf(initialTheme?.textureId) }
var editingColorType by remember { mutableStateOf<String?>(null) }
val contrast = calculateContrastRatio(bgColor, txtColor)
val isDark = bgColor.luminance() < 0.5f
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.65f).padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onCancel) { Text("Cancel") }
Text(if (initialTheme == null) "New Theme" else "Edit Theme", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
TextButton(onClick = {
onSave(ReaderTheme(id = initialTheme?.id ?: System.currentTimeMillis().toString(), name = name, backgroundColor = bgColor, textColor = txtColor, isDark = isDark, textureId = textureId, isCustom = true))
}) { Text("Save") }
}
androidx.compose.material3.OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Theme Name") },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
singleLine = true
)
// Live Preview Card
Surface(
modifier = Modifier.fillMaxWidth().height(120.dp).padding(vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
color = bgColor,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
// Draw Texture if selected (kept logic for future)
val context = LocalContext.current
Box(modifier = Modifier.fillMaxSize().run {
val texRes = ReaderTexture.entries.find { it.id == textureId }?.resId
if (texRes != null) {
val bmp = ImageBitmap.imageResource(context.resources, texRes)
this.drawBehind {
drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated)), blendMode = BlendMode.Multiply, alpha = 0.5f)
}
} else this
}) {
Column(Modifier.padding(16.dp).fillMaxWidth()) {
Text(
text = "So many books, so little time.",
color = txtColor,
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
Text(
text = "- Frank Zappa",
color = txtColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End
)
}
}
}
// Animated Contrast Warning
AnimatedVisibility(visible = contrast < 4.5f) {
Text(
"⚠️ Low contrast! This might cause eye strain.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Spacer(Modifier.height(16.dp))
// Sleek Color Swatches
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
ColorSwatchItem(
label = "Page Color",
color = bgColor,
onClick = { editingColorType = "bg" },
modifier = Modifier.weight(1f)
)
ColorSwatchItem(
label = "Text Color",
color = txtColor,
onClick = { editingColorType = "text" },
modifier = Modifier.weight(1f)
)
}
// Texture UI hidden for now
/*
Spacer(Modifier.height(16.dp))
Text("Texture", style = MaterialTheme.typography.labelMedium)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
TextureOption("None", null, textureId == null) { textureId = null }
ReaderTexture.entries.forEach { tex ->
TextureOption(tex.displayName, tex.resId, textureId == tex.id) { textureId = tex.id }
}
}
*/
Spacer(Modifier.height(16.dp))
}
editingColorType?.let { type ->
ThemeColorPickerDialog(
initialColor = if (type == "bg") bgColor else txtColor,
title = if (type == "bg") "Page Color" else "Text Color",
bgColor = bgColor,
textColor = txtColor,
editingColorType = type,
onDismiss = { editingColorType = null },
onColorChanged = { newColor ->
if (type == "bg") bgColor = newColor else txtColor = newColor
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSwatchItem(label: String, color: Color, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(bottom = 8.dp))
Surface(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
color = color,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().height(56.dp)
) {}
}
}
@Composable
fun ThemeColorPickerDialog(
initialColor: Color,
title: String,
bgColor: Color,
textColor: Color,
editingColorType: String,
onDismiss: () -> Unit,
onColorChanged: (Color) -> Unit
) {
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
var value by remember { mutableFloatStateOf(initialHsv[2]) }
val currentColor by remember {
derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
val argb = android.graphics.Color.HSVToColor(255, hsv)
Color(argb)
}
}
LaunchedEffect(currentColor) {
onColorChanged(currentColor)
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
androidx.compose.ui.window.Dialog(
onDismissRequest = onDismiss,
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(8.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
Spacer(Modifier.height(16.dp))
val liveBgColor = if (editingColorType == "bg") currentColor else bgColor
val liveTextColor = if (editingColorType == "text") currentColor else textColor
Surface(
modifier = Modifier.fillMaxWidth().height(64.dp),
shape = RoundedCornerShape(12.dp),
color = liveBgColor,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Live Preview",
color = liveTextColor,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Reading is dreaming.",
color = liveTextColor,
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s -> hue = h; saturation = s },
modifier = Modifier.fillMaxWidth().height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier.fillMaxWidth().height(24.dp).clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = initialColor,
newColor = currentColor,
modifier = Modifier.width(64.dp).height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1)
Spacer(Modifier.height(4.dp))
HexInput(color = currentColor, onHexChanged = { updateFromColor(it) })
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(
label = "R", value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "G", value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "B", value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onDismiss,
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Text("Done")
}
}
}
}
}
}
@Composable
fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) {
Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run {
if (resId != null) {
val bmp = ImageBitmap.imageResource(LocalResources.current, resId)
this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) }
} else this.background(MaterialTheme.colorScheme.surfaceVariant)
})
Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
}
}
@Composable
fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f))
Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f))
Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f))
}
}

View file

@ -107,6 +107,8 @@ class BookPaginator(
private val density: Density,
private val fontFamilyMap: Map<String, FontFamily>,
private val isDarkTheme: Boolean,
private val themeBackgroundColor: Color,
private val themeTextColor: Color,
private val bookId: String,
private val initialChapterToPaginate: Int,
private val bookCss: Map<String, String>,
@ -127,7 +129,7 @@ class BookPaginator(
override var generation by mutableIntStateOf(0)
private set
override val pageShiftRequest = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val pageShiftRequest = MutableSharedFlow<Int>(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val currentUserChapterIndex = MutableStateFlow(initialChapterToPaginate)
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
@ -404,6 +406,8 @@ class BookPaginator(
fontFamilyMap = fontFamilyMap,
density = density,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign
@ -475,10 +479,10 @@ class BookPaginator(
val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false)
val uaResult = CssParser.parse(cssContent = userAgentStylesheet, cssPath = null, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
parsingCssRules = parsingCssRules.merge(uaResult.rules)
bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false)
val bookCssResult = CssParser.parse(cssContent = content, cssPath = path, baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, isDarkTheme = false, themeBackgroundColor = themeBackgroundColor, themeTextColor = themeTextColor)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
@ -950,8 +954,8 @@ class BookPaginator(
return@launch
}
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) {
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -983,8 +987,8 @@ class BookPaginator(
val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) {
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -1055,8 +1059,8 @@ class BookPaginator(
val targetChapterIndex = locator.chapterIndex
Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}")
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -1068,6 +1072,8 @@ class BookPaginator(
for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
if (block.blockIndex == locator.blockIndex) {
Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex")
if (fallbackPageInChapter == -1) {
fallbackPageInChapter = pageIndex
}
@ -1077,19 +1083,20 @@ class BookPaginator(
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
if (locator.charOffset in startOffsetOnPage..<endOffsetOnPage) {
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
Timber.tag("ThemeReconfig").d("Offset Check: Target ${locator.charOffset} vs Range [$startOffsetOnPage, $endOffsetOnPage]. Inside: $isInside")
if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.i("Locator navigation SUCCEEDED with offset. Final page index: $finalPageIndex")
return finalPageIndex
}
} else {
val finalPageIndex = chapterStartPage + pageIndex
Timber.i("Locator navigation SUCCEEDED for non-text block. Final page index: $finalPageIndex")
return finalPageIndex
return chapterStartPage + pageIndex
}
}
}
}
Timber.tag("ThemeReconfig").e("Block Index NOT FOUND: Could not find block ${locator.blockIndex} in any page of Chapter $targetChapterIndex")
if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter
@ -1120,9 +1127,9 @@ class BookPaginator(
coroutineScope.launch(Dispatchers.IO) {
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
if (chapterPages == null) {
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.")

View file

@ -50,6 +50,8 @@ class ContentStyler(
private val fontFamilyMap: Map<String, FontFamily>,
private val density: Density,
private val isDarkTheme: Boolean,
private val themeBackgroundColor: Color,
private val themeTextColor: Color,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?
@ -59,7 +61,6 @@ class ContentStyler(
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
}
// ADD this function to group floating blocks, similar to the original parser
private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
if (blocks.isEmpty()) return emptyList()
@ -71,7 +72,6 @@ class ContentStyler(
val floatDirection = (currentBlock as? ImageBlock)?.style?.float
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
val floatedImage = currentBlock
val paragraphsToWrap = mutableListOf<ParagraphBlock>()
while (processingQueue.isNotEmpty()) {
@ -86,11 +86,11 @@ class ContentStyler(
}
}
val wrappingBlock = WrappingContentBlock(
floatedImage,
currentBlock,
paragraphsToWrap,
elementId = floatedImage.elementId,
cfi = floatedImage.cfi,
blockIndex = floatedImage.blockIndex
elementId = currentBlock.elementId,
cfi = currentBlock.cfi,
blockIndex = currentBlock.blockIndex
)
result.add(wrappingBlock)
} else {
@ -208,7 +208,7 @@ class ContentStyler(
private fun applyThemeToStyle(style: CssStyle): CssStyle {
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false)
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} else {
original.color
}
@ -217,14 +217,14 @@ class ContentStyler(
val newBlockStyle = style.blockStyle.let { original ->
val newBgColor = if (original.backgroundColor.isSpecified) {
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true)
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
} else {
original.backgroundColor
}
fun themeBorder(b: BorderStyle?): BorderStyle? {
if (b == null) return null
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false)
val newColor = CssParser.adaptColorForTheme(b.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
return b.copy(color = newColor)
}
@ -474,7 +474,7 @@ class ContentStyler(
}
private fun toRoman(number: Int): String {
if (number < 1 || number > 3999) return number.toString()
if (number !in 1..3999) return number.toString()
val values = listOf(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
val symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
val result = StringBuilder()

View file

@ -19,9 +19,6 @@
*/
package com.aryan.reader.paginatedreader
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
@ -38,6 +35,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import timber.log.Timber
import java.io.File
import java.util.regex.Pattern
import kotlin.math.roundToInt
@ -67,24 +65,64 @@ object CssParser {
"thick" to 5.dp
)
internal fun adaptColorForTheme(color: Color, isDarkTheme: Boolean, isBackground: Boolean): Color {
internal fun adaptColorForTheme(
color: Color,
isDarkTheme: Boolean,
isBackground: Boolean,
themeBackground: Color = Color.Unspecified,
themeText: Color = Color.Unspecified
): Color {
if (!color.isSpecified) return color
if (color.alpha < 0.9f) return color
if (color.alpha < 0.9f && color != Color.Transparent) return color
if (color == Color.Transparent) return color
val luminance = color.luminance()
return if (isDarkTheme) {
if (isBackground) {
if (luminance > 0.9) Color.Transparent else color
if (!themeBackground.isSpecified || !themeText.isSpecified) {
val luminance = color.luminance()
return if (isDarkTheme) {
if (isBackground) {
if (luminance > 0.9) Color.Transparent else color
} else {
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
}
} else {
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
if (isBackground) {
if (luminance < 0.1) Color.Transparent else color
} else {
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
}
}
}
val bgLuminance = themeBackground.luminance()
val colorLuminance = color.luminance()
val l1 = maxOf(bgLuminance, colorLuminance)
val l2 = minOf(bgLuminance, colorLuminance)
val contrast = (l1 + 0.05f) / (l2 + 0.05f)
if (isBackground) {
return if (isDarkTheme && colorLuminance > 0.5f) {
Color.Transparent
} else if (!isDarkTheme && colorLuminance < 0.2f) {
Color.Transparent
} else {
color
}
} else {
if (isBackground) {
if (luminance < 0.1) Color.Transparent else color
} else {
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
if (contrast >= 4.5f) {
return color
}
val hsl = FloatArray(3)
androidx.core.graphics.ColorUtils.colorToHSL(color.toArgb(), hsl)
if (bgLuminance < 0.5f) {
hsl[2] = hsl[2].coerceAtLeast(0.7f)
} else {
hsl[2] = hsl[2].coerceAtMost(0.3f)
}
return Color(androidx.core.graphics.ColorUtils.HSLToColor(hsl))
}
}
@ -138,7 +176,9 @@ object CssParser {
baseFontSizeSp: Float,
density: Float,
constraints: Constraints,
isDarkTheme: Boolean
isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
): OptimizedCssParseResult {
val byTag = mutableMapOf<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>()
@ -192,11 +232,11 @@ object CssParser {
val specificity = calculateSpecificity(originalSelector)
val normalStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
isDarkTheme
isDarkTheme, themeBackgroundColor, themeTextColor
)
val importantStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
isDarkTheme
isDarkTheme, themeBackgroundColor, themeTextColor
)
fun addRule(style: CssStyle, spec: Int) {
@ -335,7 +375,9 @@ object CssParser {
density: Float,
constraints: Constraints,
onlyImportant: Boolean,
isDarkTheme: Boolean
isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
): CssStyle {
var spanStyle = SpanStyle()
var paragraphStyle = ParagraphStyle()
@ -427,7 +469,7 @@ object CssParser {
styleStr: String?
) {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
val isExplicitWidth = widthStr != null
@ -482,7 +524,7 @@ object CssParser {
}
"color" -> {
parseColor(value)?.let {
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false))
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor))
}
}
"text-align" -> {
@ -587,7 +629,7 @@ object CssParser {
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
}
// Border Properties
@ -727,7 +769,7 @@ object CssParser {
textEmphasisStyleString = value
}
"text-emphasis-color", "-epub-text-emphasis-color" -> {
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) }
}
"text-emphasis-position", "-epub-text-emphasis-position" -> {
if (value in listOf("over", "under")) {
@ -785,7 +827,7 @@ object CssParser {
val finalStyle = style ?: "none"
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false)
val adaptedColor = this@CssParser.adaptColorForTheme(finalColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
return BorderStyle(finalWidth, adaptedColor, finalStyle)

View file

@ -121,6 +121,7 @@ import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.PlatformTextStyle
@ -490,6 +491,8 @@ private fun WrappingContentLayout(
fun PaginatedReaderScreen(
book: EpubBook,
isDarkTheme: Boolean,
effectiveBg: Color,
effectiveText: Color,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String,
@ -513,7 +516,8 @@ fun PaginatedReaderScreen(
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit
onUpdatePalette: (Int, HighlightColor) -> Unit,
activeTextureId: String? = null
) {
LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.")
@ -522,10 +526,26 @@ fun PaginatedReaderScreen(
}
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val context = LocalContext.current
val textureBitmap = remember(activeTextureId) {
activeTextureId?.let { id ->
com.aryan.reader.epubreader.ReaderTexture.entries.find { it.id == id }?.resId?.let { resId ->
androidx.compose.ui.graphics.ImageBitmap.imageResource(context.resources, resId)
}
}
}
val textureModifier = if (textureBitmap != null) {
Modifier.drawBehind {
val brush = androidx.compose.ui.graphics.ShaderBrush(
androidx.compose.ui.graphics.ImageShader(textureBitmap, androidx.compose.ui.graphics.TileMode.Repeated, androidx.compose.ui.graphics.TileMode.Repeated)
)
drawRect(brush = brush, blendMode = BlendMode.Multiply, alpha = 0.6f)
}
} else Modifier
BoxWithConstraints(modifier = modifier.fillMaxSize().background(effectiveBg).then(textureModifier)) {
val textMeasurer = rememberTextMeasurer()
val textColor = if (isDarkTheme) MaterialTheme.colorScheme.onBackground
else MaterialTheme.colorScheme.onSurface
val baseTextStyle = MaterialTheme.typography.bodyLarge
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
@ -536,22 +556,36 @@ fun PaginatedReaderScreen(
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
val previousConstraints = remember { arrayOf(this.constraints) }
if (previousConstraints[0] != this.constraints) {
val previousState = remember {
arrayOf<Any>(this.constraints, isDarkTheme, effectiveBg, effectiveText)
}
if (previousState[0] != this.constraints ||
previousState[1] != isDarkTheme ||
previousState[2] != effectiveBg ||
previousState[3] != effectiveText
) {
val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(currentPage)
if (locator != null) {
anchorLocatorForReconfig = locator
}
anchorLocatorForReconfig = locator
Timber.tag("ThemeReconfig").d("""
RECONFIG DETECTED
- Reason: ${if (previousState[0] != this.constraints) "Constraints" else "Theme/Colors"}
- Current Page: $currentPage
- Saved Locator: $locator
""".trimIndent())
}
previousConstraints[0] = this.constraints
previousState[0] = this.constraints
previousState[1] = isDarkTheme
previousState[2] = effectiveBg
previousState[3] = effectiveText
}
val textStyle = remember(
baseTextStyle,
textColor,
baseTextStyle, effectiveText,
debouncedFontSizeMult,
debouncedLineHeightMult,
debouncedFontFamily
@ -560,7 +594,7 @@ fun PaginatedReaderScreen(
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
baseTextStyle.copy(
color = textColor,
color = effectiveText,
fontSize = adjustedFontSize,
lineHeight = adjustedLineHeight,
fontFamily = debouncedFontFamily,
@ -636,7 +670,7 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0
}
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign) {
val paginator = remember(book, textConstraints, isDarkTheme, textStyle, userTextAlign, effectiveBg, effectiveText) {
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
@ -647,7 +681,9 @@ fun PaginatedReaderScreen(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
)
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
@ -659,7 +695,9 @@ fun PaginatedReaderScreen(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
)
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
@ -687,6 +725,8 @@ fun PaginatedReaderScreen(
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText,
bookId = uniqueBookId,
bookCacheDao = bookCacheDao,
proto = proto,
@ -707,31 +747,28 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) {
Timber.d("Waiting for paginator to initialize before restoring anchor...")
Timber.tag("ThemeReconfig").d("Restoration Effect Triggered for Locator: $anchorLocatorForReconfig")
// Suspend until isLoading becomes false
snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) {
Timber.d(
"Paginator initialized. Restoring anchor: Chapter=${targetLocator.chapterIndex}, Block=${targetLocator.blockIndex}, Offset=${targetLocator.charOffset}"
)
val page = paginator.findPageForLocator(targetLocator)
Timber.tag("ThemeReconfig").d("""
Restoration Progress:
- Target Locator: $targetLocator
- Paginator found Page: $page
- Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]}
""".trimIndent())
if (page != null) {
pagerState.scrollToPage(page)
Timber.d("Restored to page: $page")
} else {
val startPage =
paginator.chapterStartPageIndices[targetLocator.chapterIndex]
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.w(
"Exact locator not found. Falling back to start of chapter at page $startPage"
)
Timber.tag("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
} else {
Timber.e("Failed to restore position. Chapter start index not found.")
}
}
anchorLocatorForReconfig = null
@ -780,6 +817,7 @@ fun PaginatedReaderScreen(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
effectiveBg = effectiveBg,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
textStyle = textStyle,
@ -814,7 +852,8 @@ fun PaginatedReaderScreen(
onHighlightDeleted = onHighlightDeleted,
isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette
onUpdatePalette = onUpdatePalette,
effectiveText = effectiveText
)
}
}
@ -1366,6 +1405,8 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
effectiveBg: Color,
effectiveText: Color,
searchQuery: String,
ttsHighlightInfo: TtsHighlightInfo?,
textStyle: TextStyle,
@ -1526,7 +1567,7 @@ internal fun PaginatedReaderContent(
val pageModifier = if (isPageTurnAnimationEnabled) {
Modifier
.zIndex(zIndex)
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED
.realisticBookPage(pagerState, pageIndex, effectiveBg, isDarkTheme, pageTurnTouchY)
} else {
Modifier
}
@ -3396,6 +3437,7 @@ private fun RenderFlexChildBlock(
private fun Modifier.realisticBookPage(
pagerState: PagerState,
pageIndex: Int,
paperColor: Color,
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
@ -3419,7 +3461,6 @@ private fun Modifier.realisticBookPage(
}
.drawWithContent {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor)
@ -3507,8 +3548,10 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) {
clipPath(frontPath) {
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
drawPath(reflectedScreenPath, color = flapColor)
drawPath(reflectedScreenPath, color = paperColor)
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint)
val innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient(

View file

@ -72,6 +72,8 @@ class PaginatedReaderViewModel : ViewModel() {
textStyle: TextStyle,
density: Density,
isDarkTheme: Boolean,
themeBackgroundColor: androidx.compose.ui.graphics.Color,
themeTextColor: androidx.compose.ui.graphics.Color,
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer
@ -83,7 +85,7 @@ class PaginatedReaderViewModel : ViewModel() {
// CSS Parsing and Font Loading
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules() // CHANGED from: mutableListOf<CssRule>()
var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>()
val uaResult = CssParser.parse(
@ -92,9 +94,11 @@ class PaginatedReaderViewModel : ViewModel() {
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
allRules = allRules.merge(uaResult.rules) // CHANGED
allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces)
book.css.forEach { (path, content) ->
@ -104,9 +108,11 @@ class PaginatedReaderViewModel : ViewModel() {
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor
)
allRules = allRules.merge(bookCssResult.rules) // CHANGED
allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces)
}
val fontFamilyMap = loadFontFamilies(
@ -125,6 +131,8 @@ class PaginatedReaderViewModel : ViewModel() {
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
bookId = bookId,
bookCacheDao = bookCacheDao,
proto = proto,

View file

@ -23,10 +23,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -40,8 +37,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -54,7 +49,6 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -71,7 +65,6 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb
@ -79,15 +72,16 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.toColorInt
import com.aryan.reader.BrightnessSlider
import com.aryan.reader.ColorComparePill
import com.aryan.reader.HexInput
import com.aryan.reader.RgbInputColumn
import com.aryan.reader.SpectrumBox
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@ -589,285 +583,6 @@ private fun ColorPickerDialog(
}
}
@Composable
private fun SpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(h, s)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(
brush = Brush.horizontalGradient(rainbowColors)
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
val pointerRadius = 10.dp.toPx()
val strokeWidth = 2.dp.toPx()
drawCircle(
color = Color.Black.copy(alpha = 0.25f),
radius = pointerRadius + 1.dp.toPx(),
center = Offset(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
private fun BrightnessSlider(
hue: Float,
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val baseColor = remember(hue, saturation) {
Color.hsv(hue, saturation, 1f)
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(v)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, baseColor)
)
)
val x = value * size.width
drawCircle(
color = Color.White,
radius = 8.dp.toPx(),
center = Offset(x, size.height / 2)
)
}
}
}
@Composable
private fun RgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
RgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
private fun RgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.toString()) }
LaunchedEffect(value) {
text = value.toString()
}
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
val intVal = newText.toIntOrNull()
if (intVal != null) {
onValueChange(intVal.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
private fun HexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = remember(color) {
String.format("%06X", (0xFFFFFF and color.toArgb()))
}
var text by remember(hexValue) { mutableStateOf(hexValue) }
LaunchedEffect(color) {
val currentParsed = try {
Color(("#$text").toColorInt())
} catch (_: Exception) {
null
}
if (currentParsed?.toArgb() != color.toArgb()) {
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
try {
val parsedColorInt = "#$uppercased".toColorInt()
val newColor = Color(parsedColorInt)
onHexChanged(newColor)
} catch (_: Exception) {
}
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
private fun ColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
}
@Composable
private fun PenItem(
type: PenType,

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q398,880 325,848.5Q252,817 197.5,762.5Q143,708 111.5,635Q80,562 80,480Q80,397 112.5,324Q145,251 200.5,197Q256,143 330,111.5Q404,80 488,80Q568,80 639,107.5Q710,135 763.5,183.5Q817,232 848.5,298.5Q880,365 880,442Q880,557 810,618.5Q740,680 640,680L566,680Q557,680 553.5,685Q550,690 550,696Q550,708 565,730.5Q580,753 580,782Q580,832 552.5,856Q525,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM303,503Q320,486 320,460Q320,434 303,417Q286,400 260,400Q234,400 217,417Q200,434 200,460Q200,486 217,503Q234,520 260,520Q286,520 303,503ZM423,343Q440,326 440,300Q440,274 423,257Q406,240 380,240Q354,240 337,257Q320,274 320,300Q320,326 337,343Q354,360 380,360Q406,360 423,343ZM623,343Q640,326 640,300Q640,274 623,257Q606,240 580,240Q554,240 537,257Q520,274 520,300Q520,326 537,343Q554,360 580,360Q606,360 623,343ZM743,503Q760,486 760,460Q760,434 743,417Q726,400 700,400Q674,400 657,417Q640,434 640,460Q640,486 657,503Q674,520 700,520Q726,520 743,503ZM480,800Q489,800 494.5,795Q500,790 500,782Q500,768 485,749Q470,730 470,692Q470,650 499,625Q528,600 570,600L640,600Q706,600 753,561.5Q800,523 800,442Q800,321 707.5,240.5Q615,160 488,160Q352,160 256,253Q160,346 160,480Q160,613 253.5,706.5Q347,800 480,800Z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB