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.VIEWPORT_PADDING_BOTTOM = bottom || 0;
}; };
window.applyReaderTheme = function (isDark) { window.applyReaderTheme = function (isDark, bgHex, textHex, textureBase64) {
var styleId = "readerThemeStyle"; var styleId = "readerThemeStyle";
var themeStyleElement = document.getElementById(styleId); var themeStyleElement = document.getElementById(styleId);
@ -199,65 +199,185 @@
document.head.appendChild(themeStyleElement); document.head.appendChild(themeStyleElement);
} }
// Set a class on the root element for theme state
var themeClassName = isDark ? "dark-theme" : "light-theme"; var themeClassName = isDark ? "dark-theme" : "light-theme";
var oppositeThemeClassName = isDark ? "light-theme" : "dark-theme"; var oppositeThemeClassName = isDark ? "light-theme" : "dark-theme";
document.documentElement.classList.remove(oppositeThemeClassName); document.documentElement.classList.remove(oppositeThemeClassName);
document.documentElement.classList.add(themeClassName); document.documentElement.classList.add(themeClassName);
var css = ""; var effectiveBg = bgHex || (isDark ? '#121212' : '#FFFFFF');
var effectiveText = textHex || (isDark ? '#E0E0E0' : '#000000');
if (isDark) { var textureCss = textureBase64
css = ` html.dark-theme, html.dark-theme body { ? `background-image: url('${textureBase64}'); background-repeat: repeat; background-blend-mode: multiply;`
background-color: #121212 !important; : 'background-image: none;';
color: #E0E0E0 !important;
}
html.dark-theme a { var css = `
color: #BB86FC !important; :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.${themeClassName} a {
html.dark-theme a div, color: ${isDark ? '#BB86FC' : '#1A0DAB'} !important;
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.dark-theme blockquote, html.dark-theme pre, html.${themeClassName} a p,
html.dark-theme figcaption, html.dark-theme caption, html.${themeClassName} a div,
html.dark-theme label, html.dark-theme dt, html.dark-theme dd { html.${themeClassName} a span,
color: inherit !important; html.${themeClassName} a li,
background-color: transparent !important; 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 { html.${themeClassName} blockquote, html.${themeClassName} pre,
border-color: #444444 !important; html.${themeClassName} figcaption, html.${themeClassName} caption,
background-color: #444444 !important; 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 { html.${themeClassName} hr {
background-color: transparent !important; border-color: ${isDark ? '#444444' : '#CCCCCC'} !important;
border-color: #555 !important; background-color: ${isDark ? '#444444' : '#CCCCCC'} !important;
} }
`; html.${themeClassName} table, html.${themeClassName} tr, html.${themeClassName} td, html.${themeClassName} th {
} else { background-color: transparent !important;
css = ` html.light-theme { border-color: ${isDark ? '#555' : '#CCC'} !important;
background-color: #FFFFFF; }
} `;
`;
}
themeStyleElement.innerHTML = css; 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) { function handleHighlightInteraction(e) {

View file

@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.Voice 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.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize 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.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -103,9 +108,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.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.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager 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.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -156,6 +172,8 @@ import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import kotlin.math.roundToInt
const val aiServerBasePath = BuildConfig.AI_WORKER_URL const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize" const val summarizeEndpoint = "/summarize"
@ -1717,4 +1735,283 @@ 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, onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit, onOpenDeviceVoiceSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit, onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null, onToggleReflow: (() -> Unit)? = null,
@ -209,6 +210,13 @@ fun EpubReaderTopBar(
contentDescription = "Dictionary Settings" contentDescription = "Dictionary Settings"
) )
} }
TooltipIconButton(
text = "Theme",
description = "Theme Settings",
onClick = onOpenThemeSettings
) {
Icon(painter = painterResource(id = R.drawable.palette), contentDescription = "Theme Settings")
}
Box { Box {
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
TooltipIconButton( TooltipIconButton(

View file

@ -29,11 +29,13 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager 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.graphics.Bitmap
import android.media.AudioManager 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.net.Uri
import android.os.Build import android.os.Build
import android.webkit.WebView import android.webkit.WebView
@ -51,14 +53,20 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward 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.material.icons.filled.Info
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
@ -116,19 +133,32 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.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.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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -143,6 +173,7 @@ import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.RenderMode import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
@ -335,6 +366,94 @@ private fun saveExternalSearchPackage(context: Context, packageName: String) {
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) } 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) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
fun EpubReaderScreen( fun EpubReaderScreen(
@ -957,9 +1076,33 @@ fun EpubReaderHost(
} }
var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet 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 { val currentChapterInPaginatedMode by remember {
derivedStateOf { derivedStateOf {
@ -1892,6 +2035,7 @@ fun EpubReaderHost(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(effectiveBg)
.padding(scaffoldPaddingValues) .padding(scaffoldPaddingValues)
.focusRequester(containerFocusRequester) .focusRequester(containerFocusRequester)
.focusable() .focusable()
@ -2080,6 +2224,8 @@ fun EpubReaderHost(
key = chapterKeyForWebView, key = chapterKeyForWebView,
chapterTitle = chapterToRender.title, chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
initialScrollTarget = initialScrollTargetForChapter, initialScrollTarget = initialScrollTargetForChapter,
initialPageScrollY = currentScrollYPosition, initialPageScrollY = currentScrollYPosition,
initialCfi = cfiToLoad, initialCfi = cfiToLoad,
@ -2276,6 +2422,7 @@ fun EpubReaderHost(
currentFontFamily = currentFontFamily, currentFontFamily = currentFontFamily,
customFontPath = currentCustomFontPath, customFontPath = currentCustomFontPath,
currentTextAlign = currentTextAlign, currentTextAlign = currentTextAlign,
activeTextureId = activeTextureId,
onHighlightClicked = { onHighlightClicked = {
lastHighlightClickTime = System.currentTimeMillis() lastHighlightClickTime = System.currentTimeMillis()
showBars = false showBars = false
@ -2622,6 +2769,8 @@ fun EpubReaderHost(
PaginatedReaderScreen( PaginatedReaderScreen(
book = epubBook, book = epubBook,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
pagerState = paginatedPagerState, pagerState = paginatedPagerState,
searchQuery = searchState.searchQuery, searchQuery = searchState.searchQuery,
fontSizeMultiplier = currentFontSizeEm, fontSizeMultiplier = currentFontSizeEm,
@ -2636,6 +2785,7 @@ fun EpubReaderHost(
cfi = ttsState.sourceCfi ?: "", cfi = ttsState.sourceCfi ?: "",
offset = ttsState.startOffsetInSource offset = ttsState.startOffsetInSource
).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 }, ).takeIf { ttsState.currentText != null && ttsState.sourceCfi != null && ttsState.startOffsetInSource != -1 },
activeTextureId = activeTextureId,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex, initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f), modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
onPaginatorReady = { newPaginator -> onPaginatorReady = { newPaginator ->
@ -3305,6 +3455,7 @@ fun EpubReaderHost(
onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onToggleReflow = if (onToggleReflow != null) { onToggleReflow = if (onToggleReflow != null) {
{ {
val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) { val activeChapter = if (currentRenderMode == RenderMode.PAGINATED) {
@ -3876,5 +4027,516 @@ fun EpubReaderHost(
Spacer(Modifier.height(16.dp)) 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 density: Density,
private val fontFamilyMap: Map<String, FontFamily>, private val fontFamilyMap: Map<String, FontFamily>,
private val isDarkTheme: Boolean, private val isDarkTheme: Boolean,
private val themeBackgroundColor: Color,
private val themeTextColor: Color,
private val bookId: String, private val bookId: String,
private val initialChapterToPaginate: Int, private val initialChapterToPaginate: Int,
private val bookCss: Map<String, String>, private val bookCss: Map<String, String>,
@ -127,7 +129,7 @@ class BookPaginator(
override var generation by mutableIntStateOf(0) override var generation by mutableIntStateOf(0)
private set 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) private val currentUserChapterIndex = MutableStateFlow(initialChapterToPaginate)
internal val chapterPageCounts = ConcurrentHashMap<Int, Int>() internal val chapterPageCounts = ConcurrentHashMap<Int, Int>()
@ -404,6 +406,8 @@ class BookPaginator(
fontFamilyMap = fontFamilyMap, fontFamilyMap = fontFamilyMap,
density = density, density = density,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
themeBackgroundColor = themeBackgroundColor,
themeTextColor = themeTextColor,
chapterAbsPath = chapter.absPath, chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath, extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign userTextAlign = userTextAlign
@ -475,10 +479,10 @@ class BookPaginator(
val processedHtml = document.outerHtml() val processedHtml = document.outerHtml()
var parsingCssRules = OptimizedCssRules() 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) parsingCssRules = parsingCssRules.merge(uaResult.rules)
bookCss.forEach { (path, content) -> 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) parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
} }
@ -950,8 +954,8 @@ class BookPaginator(
return@launch return@launch
} }
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) { if (chapterPages == null) {
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.") Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -983,8 +987,8 @@ class BookPaginator(
val targetChapterIndex = result.locationInSource val targetChapterIndex = result.locationInSource
Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex") Timber.i("Finding page for search result: '${result.query}' in chapter $targetChapterIndex")
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) { if (chapterPages == null) {
Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.") Timber.e("Search result navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -1055,8 +1059,8 @@ class BookPaginator(
val targetChapterIndex = locator.chapterIndex val targetChapterIndex = locator.chapterIndex
Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}") 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 chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
if (chapterPages.isNullOrEmpty()) { if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.") Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
@ -1068,6 +1072,8 @@ class BookPaginator(
for ((pageIndex, page) in chapterPages.withIndex()) { for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) { for (block in page.content) {
if (block.blockIndex == locator.blockIndex) { 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) { if (fallbackPageInChapter == -1) {
fallbackPageInChapter = pageIndex fallbackPageInChapter = pageIndex
} }
@ -1077,19 +1083,20 @@ class BookPaginator(
val startOffsetOnPage = textBlock.startCharOffsetInSource val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length 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 val finalPageIndex = chapterStartPage + pageIndex
Timber.i("Locator navigation SUCCEEDED with offset. Final page index: $finalPageIndex")
return finalPageIndex return finalPageIndex
} }
} else { } else {
val finalPageIndex = chapterStartPage + pageIndex return chapterStartPage + pageIndex
Timber.i("Locator navigation SUCCEEDED for non-text block. Final page index: $finalPageIndex")
return finalPageIndex
} }
} }
} }
} }
Timber.tag("ThemeReconfig").e("Block Index NOT FOUND: Could not find block ${locator.blockIndex} in any page of Chapter $targetChapterIndex")
if (fallbackPageInChapter != -1) { if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter val finalPageIndex = chapterStartPage + fallbackPageInChapter
@ -1120,9 +1127,9 @@ class BookPaginator(
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'") Timber.i("findPageForCfi: Starting search for CFI: '$cfi' in chapter: '$chapterIndex'")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
val chapterStartPage = calculateAccurateStartIndex(chapterIndex) val chapterStartPage = calculateAccurateStartIndex(chapterIndex)
Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.") Timber.d("findPageForCfi: Chapter $chapterIndex starts at absolute page $chapterStartPage.")
val chapterPages = pageCache[chapterIndex] ?: paginateChapter(chapterIndex)
if (chapterPages == null) { if (chapterPages == null) {
Timber.e("CFI Navigation failed: Could not paginate target chapter $chapterIndex.") 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 fontFamilyMap: Map<String, FontFamily>,
private val density: Density, private val density: Density,
private val isDarkTheme: Boolean, private val isDarkTheme: Boolean,
private val themeBackgroundColor: Color,
private val themeTextColor: Color,
private val chapterAbsPath: String, private val chapterAbsPath: String,
private val extractionBasePath: String, private val extractionBasePath: String,
private val userTextAlign: TextAlign? private val userTextAlign: TextAlign?
@ -59,7 +61,6 @@ class ContentStyler(
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) }) 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> { private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
if (blocks.isEmpty()) return emptyList() if (blocks.isEmpty()) return emptyList()
@ -71,7 +72,6 @@ class ContentStyler(
val floatDirection = (currentBlock as? ImageBlock)?.style?.float val floatDirection = (currentBlock as? ImageBlock)?.style?.float
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) { if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
val floatedImage = currentBlock
val paragraphsToWrap = mutableListOf<ParagraphBlock>() val paragraphsToWrap = mutableListOf<ParagraphBlock>()
while (processingQueue.isNotEmpty()) { while (processingQueue.isNotEmpty()) {
@ -86,11 +86,11 @@ class ContentStyler(
} }
} }
val wrappingBlock = WrappingContentBlock( val wrappingBlock = WrappingContentBlock(
floatedImage, currentBlock,
paragraphsToWrap, paragraphsToWrap,
elementId = floatedImage.elementId, elementId = currentBlock.elementId,
cfi = floatedImage.cfi, cfi = currentBlock.cfi,
blockIndex = floatedImage.blockIndex blockIndex = currentBlock.blockIndex
) )
result.add(wrappingBlock) result.add(wrappingBlock)
} else { } else {
@ -208,7 +208,7 @@ class ContentStyler(
private fun applyThemeToStyle(style: CssStyle): CssStyle { private fun applyThemeToStyle(style: CssStyle): CssStyle {
val newSpanStyle = style.spanStyle.let { original -> val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) { val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false) CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} else { } else {
original.color original.color
} }
@ -217,14 +217,14 @@ class ContentStyler(
val newBlockStyle = style.blockStyle.let { original -> val newBlockStyle = style.blockStyle.let { original ->
val newBgColor = if (original.backgroundColor.isSpecified) { val newBgColor = if (original.backgroundColor.isSpecified) {
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true) CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true, themeBackgroundColor, themeTextColor)
} else { } else {
original.backgroundColor original.backgroundColor
} }
fun themeBorder(b: BorderStyle?): BorderStyle? { fun themeBorder(b: BorderStyle?): BorderStyle? {
if (b == null) return null 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) return b.copy(color = newColor)
} }
@ -474,7 +474,7 @@ class ContentStyler(
} }
private fun toRoman(number: Int): String { 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 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 symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
val result = StringBuilder() val result = StringBuilder()

View file

@ -19,9 +19,6 @@
*/ */
package com.aryan.reader.paginatedreader 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.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb 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.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import timber.log.Timber
import java.io.File import java.io.File
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -67,24 +65,64 @@ object CssParser {
"thick" to 5.dp "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.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() if (!themeBackground.isSpecified || !themeText.isSpecified) {
val luminance = color.luminance()
return if (isDarkTheme) { return if (isDarkTheme) {
if (isBackground) { if (isBackground) {
if (luminance > 0.9) Color.Transparent else color if (luminance > 0.9) Color.Transparent else color
} else {
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
}
} else { } 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 { } else {
if (isBackground) { if (contrast >= 4.5f) {
if (luminance < 0.1) Color.Transparent else color return color
} else {
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else 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, baseFontSizeSp: Float,
density: Float, density: Float,
constraints: Constraints, constraints: Constraints,
isDarkTheme: Boolean isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
): OptimizedCssParseResult { ): OptimizedCssParseResult {
val byTag = mutableMapOf<String, MutableList<CssRule>>() val byTag = mutableMapOf<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>() val byClass = mutableMapOf<String, MutableList<CssRule>>()
@ -192,11 +232,11 @@ object CssParser {
val specificity = calculateSpecificity(originalSelector) val specificity = calculateSpecificity(originalSelector)
val normalStyle = parseProperties( val normalStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false, propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
isDarkTheme isDarkTheme, themeBackgroundColor, themeTextColor
) )
val importantStyle = parseProperties( val importantStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true, propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
isDarkTheme isDarkTheme, themeBackgroundColor, themeTextColor
) )
fun addRule(style: CssStyle, spec: Int) { fun addRule(style: CssStyle, spec: Int) {
@ -335,7 +375,9 @@ object CssParser {
density: Float, density: Float,
constraints: Constraints, constraints: Constraints,
onlyImportant: Boolean, onlyImportant: Boolean,
isDarkTheme: Boolean isDarkTheme: Boolean,
themeBackgroundColor: Color = Color.Unspecified,
themeTextColor: Color = Color.Unspecified
): CssStyle { ): CssStyle {
var spanStyle = SpanStyle() var spanStyle = SpanStyle()
var paragraphStyle = ParagraphStyle() var paragraphStyle = ParagraphStyle()
@ -427,7 +469,7 @@ object CssParser {
styleStr: String? styleStr: String?
) { ) {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp 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 val isExplicitWidth = widthStr != null
@ -482,7 +524,7 @@ object CssParser {
} }
"color" -> { "color" -> {
parseColor(value)?.let { 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" -> { "text-align" -> {
@ -587,7 +629,7 @@ object CssParser {
"background-color" -> { "background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified 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 // Border Properties
@ -727,7 +769,7 @@ object CssParser {
textEmphasisStyleString = value textEmphasisStyleString = value
} }
"text-emphasis-color", "-epub-text-emphasis-color" -> { "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" -> { "text-emphasis-position", "-epub-text-emphasis-position" -> {
if (value in listOf("over", "under")) { if (value in listOf("over", "under")) {
@ -785,7 +827,7 @@ object CssParser {
val finalStyle = style ?: "none" val finalStyle = style ?: "none"
val finalColor = color ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black 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") { if (finalWidth > 0.dp && finalStyle != "none" && finalStyle != "hidden") {
return BorderStyle(finalWidth, adaptedColor, finalStyle) 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.LocalViewConfiguration
import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.PlatformTextStyle
@ -490,6 +491,8 @@ private fun WrappingContentLayout(
fun PaginatedReaderScreen( fun PaginatedReaderScreen(
book: EpubBook, book: EpubBook,
isDarkTheme: Boolean, isDarkTheme: Boolean,
effectiveBg: Color,
effectiveText: Color,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean, isPageTurnAnimationEnabled: Boolean,
searchQuery: String, searchQuery: String,
@ -513,7 +516,8 @@ fun PaginatedReaderScreen(
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit onUpdatePalette: (Int, HighlightColor) -> Unit,
activeTextureId: String? = null
) { ) {
LaunchedEffect(userHighlights) { LaunchedEffect(userHighlights) {
Timber.d("PaginatedReaderScreen: Received ${userHighlights.size} highlights.") 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 textMeasurer = rememberTextMeasurer()
val textColor = if (isDarkTheme) MaterialTheme.colorScheme.onBackground
else MaterialTheme.colorScheme.onSurface
val baseTextStyle = MaterialTheme.typography.bodyLarge val baseTextStyle = MaterialTheme.typography.bodyLarge
var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) } var debouncedFontSizeMult by remember { mutableFloatStateOf(fontSizeMultiplier) }
@ -536,22 +556,36 @@ fun PaginatedReaderScreen(
var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) } var anchorLocatorForReconfig by remember { mutableStateOf<Locator?>(null) }
val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) } val currentPaginatorRef = remember { mutableStateOf<IPaginator?>(null) }
val previousConstraints = remember { arrayOf(this.constraints) } val previousState = remember {
if (previousConstraints[0] != this.constraints) { 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 val activePaginator = currentPaginatorRef.value
if (activePaginator is BookPaginator) { if (activePaginator is BookPaginator) {
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
val locator = activePaginator.getLocatorForPage(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( val textStyle = remember(
baseTextStyle, baseTextStyle, effectiveText,
textColor,
debouncedFontSizeMult, debouncedFontSizeMult,
debouncedLineHeightMult, debouncedLineHeightMult,
debouncedFontFamily debouncedFontFamily
@ -560,7 +594,7 @@ fun PaginatedReaderScreen(
val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult val adjustedLineHeight = adjustedFontSize * debouncedLineHeightMult
baseTextStyle.copy( baseTextStyle.copy(
color = textColor, color = effectiveText,
fontSize = adjustedFontSize, fontSize = adjustedFontSize,
lineHeight = adjustedLineHeight, lineHeight = adjustedLineHeight,
fontFamily = debouncedFontFamily, fontFamily = debouncedFontFamily,
@ -636,7 +670,7 @@ fun PaginatedReaderScreen(
remember(initialChapterIndexInBook, anchorLocatorForReconfig) { remember(initialChapterIndexInBook, anchorLocatorForReconfig) {
anchorLocatorForReconfig?.chapterIndex ?: initialChapterIndexInBook ?: 0 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 val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules() var allRules = OptimizedCssRules()
val allFontFaces = mutableListOf<FontFaceInfo>() val allFontFaces = mutableListOf<FontFaceInfo>()
@ -647,7 +681,9 @@ fun PaginatedReaderScreen(
baseFontSizeSp = textStyle.fontSize.value, baseFontSizeSp = textStyle.fontSize.value,
density = density.density, density = density.density,
constraints = textConstraints, constraints = textConstraints,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
) )
allRules = allRules.merge(uaResult.rules) allRules = allRules.merge(uaResult.rules)
allFontFaces.addAll(uaResult.fontFaces) allFontFaces.addAll(uaResult.fontFaces)
@ -659,7 +695,9 @@ fun PaginatedReaderScreen(
baseFontSizeSp = textStyle.fontSize.value, baseFontSizeSp = textStyle.fontSize.value,
density = density.density, density = density.density,
constraints = textConstraints, constraints = textConstraints,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText
) )
allRules = allRules.merge(bookCssResult.rules) allRules = allRules.merge(bookCssResult.rules)
allFontFaces.addAll(bookCssResult.fontFaces) allFontFaces.addAll(bookCssResult.fontFaces)
@ -687,6 +725,8 @@ fun PaginatedReaderScreen(
density = density, density = density,
fontFamilyMap = fontFamilyMap, fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
themeBackgroundColor = effectiveBg,
themeTextColor = effectiveText,
bookId = uniqueBookId, bookId = uniqueBookId,
bookCacheDao = bookCacheDao, bookCacheDao = bookCacheDao,
proto = proto, proto = proto,
@ -707,31 +747,28 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator) { LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) { 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() snapshotFlow { paginator.isLoading }.filter { !it }.first()
val targetLocator = anchorLocatorForReconfig val targetLocator = anchorLocatorForReconfig
if (targetLocator != null) { if (targetLocator != null) {
Timber.d(
"Paginator initialized. Restoring anchor: Chapter=${targetLocator.chapterIndex}, Block=${targetLocator.blockIndex}, Offset=${targetLocator.charOffset}"
)
val page = paginator.findPageForLocator(targetLocator) 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) { if (page != null) {
pagerState.scrollToPage(page) pagerState.scrollToPage(page)
Timber.d("Restored to page: $page")
} else { } else {
val startPage = val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) { if (startPage != null) {
Timber.w( Timber.tag("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage")
"Exact locator not found. Falling back to start of chapter at page $startPage"
)
pagerState.scrollToPage(startPage) pagerState.scrollToPage(startPage)
} else {
Timber.e("Failed to restore position. Chapter start index not found.")
} }
} }
anchorLocatorForReconfig = null anchorLocatorForReconfig = null
@ -780,6 +817,7 @@ fun PaginatedReaderScreen(
uiState = uiState, uiState = uiState,
pagerState = pagerState, pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled, isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
effectiveBg = effectiveBg,
searchQuery = searchQuery, searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo, ttsHighlightInfo = ttsHighlightInfo,
textStyle = textStyle, textStyle = textStyle,
@ -814,7 +852,8 @@ fun PaginatedReaderScreen(
onHighlightDeleted = onHighlightDeleted, onHighlightDeleted = onHighlightDeleted,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
activeHighlightPalette = activeHighlightPalette, activeHighlightPalette = activeHighlightPalette,
onUpdatePalette = onUpdatePalette onUpdatePalette = onUpdatePalette,
effectiveText = effectiveText
) )
} }
} }
@ -1366,6 +1405,8 @@ internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState, uiState: PaginatedReaderUiState,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean, isPageTurnAnimationEnabled: Boolean,
effectiveBg: Color,
effectiveText: Color,
searchQuery: String, searchQuery: String,
ttsHighlightInfo: TtsHighlightInfo?, ttsHighlightInfo: TtsHighlightInfo?,
textStyle: TextStyle, textStyle: TextStyle,
@ -1526,7 +1567,7 @@ internal fun PaginatedReaderContent(
val pageModifier = if (isPageTurnAnimationEnabled) { val pageModifier = if (isPageTurnAnimationEnabled) {
Modifier Modifier
.zIndex(zIndex) .zIndex(zIndex)
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED .realisticBookPage(pagerState, pageIndex, effectiveBg, isDarkTheme, pageTurnTouchY)
} else { } else {
Modifier Modifier
} }
@ -3396,6 +3437,7 @@ private fun RenderFlexChildBlock(
private fun Modifier.realisticBookPage( private fun Modifier.realisticBookPage(
pagerState: PagerState, pagerState: PagerState,
pageIndex: Int, pageIndex: Int,
paperColor: Color,
isDarkTheme: Boolean, isDarkTheme: Boolean,
touchY: Float? touchY: Float?
): Modifier = composed { ): Modifier = composed {
@ -3419,7 +3461,6 @@ private fun Modifier.realisticBookPage(
} }
.drawWithContent { .drawWithContent {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
if (abs(pageOffset) < 0.001f) { if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor) drawRect(color = paperColor)
@ -3507,8 +3548,10 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) { clipRect(0f, 0f, w, h) {
clipPath(frontPath) { clipPath(frontPath) {
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0) drawPath(reflectedScreenPath, color = paperColor)
drawPath(reflectedScreenPath, color = flapColor)
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 innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient( val innerShadowBrush = Brush.linearGradient(

View file

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

View file

@ -23,10 +23,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -54,7 +49,6 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf 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.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb 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.platform.testTag
import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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 import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @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 @Composable
private fun PenItem( private fun PenItem(
type: PenType, 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