V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -92,6 +92,8 @@ import java.io.BufferedReader
import java.io.ByteArrayOutputStream
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private fun getFontCssInjection(): String {
return """
@font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); }
@ -313,6 +315,17 @@ class FootnoteJsBridge(
}
}
@Suppress("unused")
class LinkNavJsBridge(
private val currentChapterTitle: String
) {
@JavascriptInterface
fun onLinkClicked(href: String, epubType: String, linkText: String) {
Timber.tag(TAG_LINK_NAV)
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ChapterWebView(
@ -336,6 +349,8 @@ fun ChapterWebView(
currentFontSize: Float,
currentLineHeight: Float,
currentParagraphGap: Float,
currentImageSize: Float,
currentHorizontalMargin: Float,
onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit,
@ -370,6 +385,7 @@ fun ChapterWebView(
onAutoScrollChapterEnd: () -> Unit = {},
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
activeTextureId: String? = null
) {
Timber.d(
@ -471,6 +487,8 @@ fun ChapterWebView(
currentFontSize,
currentLineHeight,
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentFontFamily,
currentTextAlign
) {
@ -550,6 +568,11 @@ fun ChapterWebView(
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("LINK_NAV:") -> {
Timber.tag(TAG_LINK_NAV)
.d("JS -> ${message.substringAfter("LINK_NAV: ")}")
}
message.startsWith("FootnoteDiag:") -> {
Timber.tag("FootnoteDiag")
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
@ -653,16 +676,31 @@ fun ChapterWebView(
}, "FootnoteBridge"
)
addJavascriptInterface(
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
Timber.d("Intercepted external link: $url")
Timber.tag(TAG_LINK_NAV)
.d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'")
showExternalLinkDialog = url
return true
}
if (url != null && url.startsWith("file://")) {
Timber.tag(TAG_LINK_NAV)
.d("[INTERNAL-LINK-INTERCEPTED] url='$url' from chapter '$chapterTitle'")
onInternalLinkClick(url)
return true
}
if (url != null) {
Timber.tag(TAG_LINK_NAV)
.d("[INTERNAL-LINK-PASSED] url='$url' from chapter '$chapterTitle' — allowing WebView to handle")
}
return false
}
@ -744,7 +782,7 @@ fun ChapterWebView(
}
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null
)
@ -768,10 +806,59 @@ fun ChapterWebView(
}
} else if (!initialFragmentId.isNullOrBlank()) {
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null
)
val js = """
(function() {
var targetId = '$initialFragmentId';
var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]');
if (el) {
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
return -2;
}
if (window.virtualization && window.virtualization.chunksData) {
for (var i = 0; i < window.virtualization.chunksData.length; i++) {
var chunkHtml = window.virtualization.chunksData[i];
if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) {
return i;
}
}
}
return -1;
})()
""".trimIndent()
view?.evaluateJavascript(js) { result ->
val chunkIdx = result?.toIntOrNull() ?: -1
if (chunkIdx >= 0) {
for (i in 0..chunkIdx) {
onChunkRequested(i)
}
val scrollJs = """
(function() {
var chunkIndex = $chunkIdx;
var fragmentId = '$initialFragmentId';
setTimeout(function() {
var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkDiv.style.height = "";
}
setTimeout(function() {
var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]');
if (el) {
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
} else if (chunkDiv) {
var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP;
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
}
}, 50);
}, 200);
})()
""".trimIndent()
view.evaluateJavascript(scrollJs, null)
}
}
onChapterInitiallyScrolled()
scrollActionTaken = true
} else if (initialScrollTarget != null) {
@ -859,7 +946,7 @@ fun ChapterWebView(
)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null
)
@ -1075,4 +1162,4 @@ fun ChapterWebView(
})
}
}
}
}

View file

@ -1378,6 +1378,7 @@ fun TtsOverlayControls(
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
@ -1504,6 +1505,14 @@ fun TtsOverlayControls(
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
painterResource(R.drawable.pin_drop),
"Locate current chunk",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
@ -1589,4 +1598,4 @@ fun TtsOverlayControls(
}
}
}
}
}

View file

@ -24,17 +24,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -42,69 +32,88 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import java.io.File
import androidx.compose.material3.Switch
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import kotlin.math.roundToInt
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size"
private const val LINE_HEIGHT_KEY = "reader_line_height"
private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap"
private const val IMAGE_SIZE_KEY = "reader_image_size"
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
private const val FONT_FAMILY_KEY = "reader_font_family"
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
@ -116,6 +125,8 @@ private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch"
@ -170,6 +181,8 @@ data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
val font: ReaderFont,
val customPath: String?,
val textAlign: ReaderTextAlign
@ -179,8 +192,11 @@ private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -198,6 +214,8 @@ fun saveLocalReaderSettings(
fontSize: Float,
lineHeight: Float,
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -207,6 +225,8 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else {
@ -260,6 +280,14 @@ fun loadPullToTurnMultiplier(context: Context): Float {
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
}
fun loadHorizontalMargin(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.contains(HORIZONTAL_MARGIN_KEY)) {
return prefs.getFloat(HORIZONTAL_MARGIN_KEY, DEFAULT_HORIZONTAL_MARGIN_VAL)
}
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -281,6 +309,18 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL)
}
val imageSize = if (isLocal && prefs.contains(LOCAL_IMAGE_SIZE_PREFIX + bookId)) {
prefs.getFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, DEFAULT_IMAGE_SIZE_VAL)
} else {
prefs.getFloat(IMAGE_SIZE_KEY, DEFAULT_IMAGE_SIZE_VAL)
}
val horizontalMargin = if (isLocal && prefs.contains(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId)) {
prefs.getFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, DEFAULT_HORIZONTAL_MARGIN_VAL)
} else {
loadHorizontalMargin(context)
}
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else {
@ -300,7 +340,16 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
}
val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT
return FormatSettings(fontSize, lineHeight, paragraphGap, font, customPath, textAlign)
return FormatSettings(
fontSize = fontSize,
lineHeight = lineHeight,
paragraphGap = paragraphGap,
imageSize = imageSize,
horizontalMargin = horizontalMargin,
font = font,
customPath = customPath,
textAlign = textAlign
)
}
fun getComposeFontFamily(
@ -339,6 +388,8 @@ fun saveReaderSettings(
fontSize: Float,
lineHeight: Float,
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -348,6 +399,8 @@ fun saveReaderSettings(
putFloat(FONT_SIZE_KEY, fontSize)
putFloat(LINE_HEIGHT_KEY, lineHeight)
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
@ -387,6 +440,7 @@ fun loadVolumeScrollSetting(context: Context): Boolean {
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderTextFormatPanel(
isVisible: Boolean,
@ -394,8 +448,12 @@ fun ReaderTextFormatPanel(
onFontSizeChange: (Float) -> Unit,
currentLineHeight: Float,
onLineHeightChange: (Float) -> Unit,
currentParagraphGap: Float, // NEW
onParagraphGapChange: (Float) -> Unit, // NEW
currentParagraphGap: Float,
onParagraphGapChange: (Float) -> Unit,
currentImageSize: Float,
onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
@ -404,27 +462,28 @@ fun ReaderTextFormatPanel(
onReset: () -> Unit,
isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
onClose: () -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
if (isVisible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
scrimColor = Color.Transparent,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
contentWindowInsets = { WindowInsets.navigationBars }
) {
val configuration = LocalConfiguration.current
val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp
Column(
modifier = Modifier.padding(16.dp)
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
.padding(bottom = 24.dp)
) {
// Header Row (Local/Global + Close/Reset)
Row(
@ -500,7 +559,7 @@ fun ReaderTextFormatPanel(
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
)
// Font Button (Full width)
// Font Button
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(12.dp),
@ -540,7 +599,7 @@ fun ReaderTextFormatPanel(
Spacer(Modifier.height(8.dp))
// Alignment Button (Full width Segmented)
// Alignment Button (Segmented)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
@ -586,49 +645,61 @@ fun ReaderTextFormatPanel(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
modifier = Modifier.padding(start = 4.dp, bottom = 12.dp)
)
// Sliders
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Size
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_font_size), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.weight(1f)
)
Text(if (currentFontSize in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
// Lines
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_line_height), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..3.0f,
steps = 19,
modifier = Modifier.weight(1f)
)
Text(if (currentLineHeight <= 1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
// Paragraph Gap
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_paragraph_gap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentParagraphGap,
onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f,
steps = 29,
modifier = Modifier.weight(1f)
)
Text(if (currentParagraphGap in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
}
// Resolve the string once outside the lambdas
val originalLabel = stringResource(R.string.label_original)
val noneLabel = stringResource(R.string.label_none)
// Wide, smooth sliders without dots
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormatSlider(
label = stringResource(R.string.label_font_size),
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_line_height),
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..3.0f,
formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_paragraph_gap),
value = currentParagraphGap,
onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_image_size),
value = currentImageSize,
onValueChange = onImageSizeChange,
valueRange = 0.5f..2.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_horizontal_margin),
value = currentHorizontalMargin,
onValueChange = onHorizontalMarginChange,
valueRange = 0.0f..3.0f,
formatValue = {
when {
it <= 0.01f -> noneLabel
it in 0.99f..1.01f -> originalLabel
else -> "%.1fx".format(it)
}
}
)
}
Spacer(Modifier.height(8.dp))
}
}
}
@ -761,8 +832,6 @@ fun VisualOptionsSheet(
onPageInfoModeChange: (PageInfoMode) -> Unit,
pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> Unit,
onDismiss: () -> Unit
@ -859,30 +928,6 @@ fun VisualOptionsSheet(
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.clickable { onRemoveEdgePaddingChange(!removeEdgePadding) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_edge_padding), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_edge_padding_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = removeEdgePadding, onCheckedChange = { onRemoveEdgePaddingChange(it) })
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
@ -922,4 +967,138 @@ fun <T> OptionSegmentedControl(
}
}
}
}
}
@Composable
fun CustomCanvasSlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
modifier: Modifier = Modifier
) {
val fraction = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f)
val activeColor = MaterialTheme.colorScheme.primary
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant
val thumbColor = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.height(24.dp) // Keeps the touch target height slim
.pointerInput(valueRange) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
val rawValue = valueRange.start + newFraction * (valueRange.endInclusive - valueRange.start)
// Snap to 0.1 intervals for consistent formatting
onValueChange((rawValue * 10f).roundToInt() / 10f)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val trackHeight = 4.dp.toPx()
val cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2)
val trackY = (size.height - trackHeight) / 2
// Draw Inactive Track
drawRoundRect(
color = inactiveColor,
topLeft = Offset(0f, trackY),
size = Size(size.width, trackHeight),
cornerRadius = cornerRadius
)
// Draw Active Track
val activeWidth = fraction * size.width
drawRoundRect(
color = activeColor,
topLeft = Offset(0f, trackY),
size = Size(activeWidth, trackHeight),
cornerRadius = cornerRadius
)
// Draw Thumb
val thumbRadius = 8.dp.toPx()
drawCircle(
color = thumbColor,
radius = thumbRadius,
center = Offset(
x = activeWidth.coerceIn(thumbRadius, size.width - thumbRadius),
y = size.height / 2
)
)
}
}
}
@Composable
fun FormatSlider(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
stepSize: Float = 0.1f,
formatValue: (Float) -> String
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = formatValue(value),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
IconButton(
onClick = {
val newValue = (value - stepSize).coerceAtLeast(valueRange.start)
onValueChange((newValue * 10f).roundToInt() / 10f)
},
modifier = Modifier.size(32.dp) // Slimmer buttons
) {
Icon(Icons.Default.Remove, contentDescription = "Decrease", tint = MaterialTheme.colorScheme.primary)
}
// Using our new CustomCanvasSlider here!
CustomCanvasSlider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
val newValue = (value + stepSize).coerceAtMost(valueRange.endInclusive)
onValueChange((newValue * 10f).roundToInt() / 10f)
},
modifier = Modifier.size(32.dp) // Slimmer buttons
) {
Icon(Icons.Default.Add, contentDescription = "Increase", tint = MaterialTheme.colorScheme.primary)
}
}
}
}

View file

@ -213,6 +213,7 @@ fun TtsSessionObserver(
fun TtsHighlightHandler(
ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode,
currentChapterIndex: Int,
webViewRef: WebView?,
paginator: IPaginator?,
pagerState: PagerState,
@ -223,14 +224,42 @@ fun TtsHighlightHandler(
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
val offset = ttsState.startOffsetInSource
val activeTtsChapterIndex = ttsState.chapterIndex ?: ttsChapterIndex
if (
currentRenderMode == RenderMode.VERTICAL_SCROLL &&
activeTtsChapterIndex != null &&
activeTtsChapterIndex != currentChapterIndex
) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Vertical highlight skipped because visible chapter differs from active TTS chapter. " +
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex " +
"cfi=${cfi?.take(48)} offset=$offset"
)
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
return@LaunchedEffect
}
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Applying vertical TTS highlight. visibleChapter=$currentChapterIndex " +
"activeTtsChapter=$activeTtsChapterIndex cfi=${cfi.take(48)} " +
"offset=$offset textLen=${text.length}"
)
}
webViewRef?.evaluateJavascript(jsCommand, null)
} else {
if (!ttsState.isPlaying && !ttsState.isLoading) {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Removing vertical TTS highlight because playback is idle. " +
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex"
)
}
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
}
}
@ -298,6 +327,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -327,6 +357,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -401,6 +432,7 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterToTry,
ttsMode = ttsMode,
playbackSource = "READER",
authToken = token
@ -421,4 +453,4 @@ private fun handlePaginatedAutoAdvance(
} else {
onUpdateTtsChapter(null)
}
}
}

View file

@ -96,7 +96,11 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// Only add NEW_TASK if we don't have an Activity context,
// preventing task switch animations for NoDisplay apps like Notification Dictionary
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
if (processTextIntent.resolveActivity(pm) != null) {
@ -148,7 +152,9 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
if (translateIntent.resolveActivity(pm) != null) {
context.startActivity(translateIntent)
@ -162,7 +168,9 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
if (processTextIntent.resolveActivity(pm) != null) {
@ -281,4 +289,15 @@ object ExternalDictionaryHelper {
return apps.sortedBy { it.label }
}
private fun Context.getActivity(): android.app.Activity? {
var currentContext = this
while (currentContext is android.content.ContextWrapper) {
if (currentContext is android.app.Activity) {
return currentContext
}
currentContext = currentContext.baseContext
}
return null
}
}