start tts from text select (#68)

* Added support for Text-to-Speech from selection in EPUB reader.

* Added support for Text-to-Speech from selection in PDF reader
This commit is contained in:
Aryan 2026-03-14 16:49:22 +05:30 committed by GitHub
parent 50af284224
commit dece09fec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 428 additions and 252 deletions

View file

@ -1038,7 +1038,7 @@
}; };
window.extractTextWithCfi = function () { window.extractTextWithCfi = function () {
const results = []; const results =[];
const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote"); const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote");
contentNodes.forEach((node) => { contentNodes.forEach((node) => {
@ -1058,6 +1058,73 @@
return jsonResult; return jsonResult;
}; };
window.extractTextWithCfiFromSelection = function() {
try {
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.toString().trim() === "") {
return window.extractTextWithCfiFromTop(); // Fallback
}
var range = selection.getRangeAt(0);
var startNode = range.startContainer;
var startOffset = range.startOffset;
const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote";
let startBlock = startNode.nodeType === Node.TEXT_NODE ? startNode.parentNode.closest(ttsNodeSelector) : startNode.closest(ttsNodeSelector);
if (!startBlock) return window.extractTextWithCfiFromTop(); // Fallback
let absoluteStartOffset = 0;
const treeWalker = document.createTreeWalker(startBlock, NodeFilter.SHOW_TEXT, null, false);
let currentNode = treeWalker.nextNode();
while (currentNode && currentNode !== startNode) {
absoluteStartOffset += currentNode.nodeValue.length;
currentNode = treeWalker.nextNode();
}
if (currentNode === startNode) {
absoluteStartOffset += startOffset;
}
const allContentNodes = Array.from(document.body.querySelectorAll(ttsNodeSelector));
const startIndex = allContentNodes.findIndex(node => node === startBlock);
if (startIndex === -1) return window.extractTextWithCfiFromTop();
const nodesToProcess = allContentNodes.slice(startIndex);
const results =[];
nodesToProcess.forEach((node, index) => {
let fullText = node.textContent || "";
if (fullText.trim().length > 0 && node.offsetParent !== null) {
try {
const cfiObj = getCfiPathForElement(node, 0);
if (cfiObj && cfiObj.cfi) {
if (index === 0) {
// Slice the very first block strictly from the selected character
let sliced = fullText.substring(absoluteStartOffset);
if (sliced.trim().length > 0) {
results.push({
cfi: cfiObj,
text: sliced,
startOffset: absoluteStartOffset
});
}
} else {
results.push({
cfi: cfiObj,
text: fullText,
startOffset: 0
});
}
}
} catch(e){}
}
});
return JSON.stringify(results);
} catch(e) {
return window.extractTextWithCfiFromTop();
}
};
window.TtsBridgeHelper = { window.TtsBridgeHelper = {
extractAndRelayText: function () { extractAndRelayText: function () {
try { try {
@ -1072,6 +1139,18 @@
} }
} }
}, },
extractAndRelayTextFromSelection: function() {
try {
const structuredTextJson = window.extractTextWithCfiFromSelection();
if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
TtsBridge.onStructuredTextExtracted(structuredTextJson);
}
} catch (e) {
if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
TtsBridge.onStructuredTextExtracted("[]");
}
}
}
}; };
window.reportTopChunk = function () { window.reportTopChunk = function () {

View file

@ -50,13 +50,8 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -74,7 +69,6 @@ import androidx.compose.ui.Modifier
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.res.painterResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@ -84,7 +78,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.aryan.reader.R import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
@ -896,7 +890,8 @@ fun ChapterWebView(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { PaginatedTextSelectionMenu(
onCopy = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", state.selectedText) val clip = ClipData.newPlainText("Copied Text", state.selectedText)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
@ -904,67 +899,60 @@ fun ChapterWebView(
localWebViewRef?.clearFocus() localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
customMenuState = null customMenuState = null
}) { },
Icon(Icons.Default.CopyAll, contentDescription = "Copy") onSelectAll = null,
} onDictionary = {
if (state.selectedText.length <= 2000) {
IconButton(onClick = {
val textToDefine = state.selectedText val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) { if (textToDefine.isNotBlank()) {
onWordSelectedForAiDefinition(textToDefine) onWordSelectedForAiDefinition(textToDefine)
} }
customMenuState = null customMenuState = null
}) { },
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary") onTranslate = {
}
IconButton(onClick = {
val textToDefine = state.selectedText val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) { if (textToDefine.isNotBlank()) {
onTranslate(textToDefine) onTranslate(textToDefine)
} }
customMenuState = null customMenuState = null
}) { },
Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate") onSearch = {
}
IconButton(onClick = {
val textToDefine = state.selectedText val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) { if (textToDefine.isNotBlank()) {
onSearch(textToDefine) onSearch(textToDefine)
} }
customMenuState = null customMenuState = null
}) { },
Icon(painterResource(id = R.drawable.search), contentDescription = "Search") onHighlight = null, // Highlight handles itself above in the Colors Row
} onTts = {
} localWebViewRef?.evaluateJavascript("javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", null)
state.finishActionModeCallback()
if (state.isExistingHighlight && state.cfi != null) { localWebViewRef?.clearFocus()
IconButton(onClick = { localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'") customMenuState = null
},
onDelete = if (state.isExistingHighlight && state.cfi != null) {
{
val highlightToDelete = userHighlights.find { h -> val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
} }
if (highlightToDelete != null) { if (highlightToDelete != null) {
val cssClassToDelete = highlightToDelete.color.cssClass val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|") val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi -> allCfiParts.forEach { partCfi ->
localWebViewRef?.evaluateJavascript( localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
null null
) )
} }
onHighlightDeleted(highlightToDelete.cfi) onHighlightDeleted(highlightToDelete.cfi)
} }
state.finishActionModeCallback() state.finishActionModeCallback()
customMenuState = null customMenuState = null
}) {
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error)
}
} }
} else null,
isProUser = isProUser,
isOss = isOss
)
} }
} }
} }

View file

@ -956,6 +956,21 @@ fun EpubReaderHost(
} }
} }
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
val currentChapterInPaginatedMode by remember {
derivedStateOf {
if (currentRenderMode == RenderMode.PAGINATED) {
(paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
} else {
null
}
}
}
fun startTts() { fun startTts() {
if (isAutoScrollModeActive) { if (isAutoScrollModeActive) {
isAutoScrollModeActive = false isAutoScrollModeActive = false
@ -1007,10 +1022,66 @@ fun EpubReaderHost(
} }
) )
var showPermissionRationaleDialog by remember { mutableStateOf(false) } fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
val isDarkTheme = isSystemInDarkTheme() val action = {
var showTtsSettingsSheet by remember { mutableStateOf(false) } scope.launch {
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
var foundIdx = -1
for (i in chunks.indices) {
val c = chunks[i]
val cPath = c.sourceCfi.substringBefore(":")
val bPath = baseCfi.substringBefore(":")
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) {
foundIdx = i
break
}
}
if (foundIdx != -1) {
val target = chunks[foundIdx]
val relativeOffset = startOffset - target.startOffsetInSource
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
val slicedText = target.text.substring(safeRelativeOffset)
val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset)
val remainingChunks = mutableListOf(newChunk)
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
if (remainingChunks.isNotEmpty()) {
ttsShouldStartOnChapterLoad = false
ttsChapterIndex = chapterIndex
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = remainingChunks,
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
}
}
}
}
if (isAutoScrollModeActive) {
isAutoScrollModeActive = false
isAutoScrollPlaying = false
}
userStoppedTts = false
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
action()
} else if (activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true) {
showPermissionRationaleDialog = true
} else {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
TtsSessionObserver( TtsSessionObserver(
ttsState = ttsState, ttsState = ttsState,
@ -1314,16 +1385,6 @@ fun EpubReaderHost(
} }
} }
val currentChapterInPaginatedMode by remember {
derivedStateOf {
if (currentRenderMode == RenderMode.PAGINATED) {
(paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
} else {
null
}
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) { LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) {
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) { if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage) val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
@ -2250,10 +2311,11 @@ fun EpubReaderHost(
val cfiJsonObject = val cfiJsonObject =
JSONObject(cfiJsonString) JSONObject(cfiJsonString)
val cfi = cfiJsonObject.getString("cfi") val cfi = cfiJsonObject.getString("cfi")
val baseOffset = jsonObject.optInt("startOffset", 0)
val subChunks = val subChunks =
splitTextIntoChunks(text) splitTextIntoChunks(text)
var currentOffset = 0 var currentOffset = baseOffset
for (subChunk in subChunks) { for (subChunk in subChunks) {
ttsChunks.add( ttsChunks.add(
TtsChunk( TtsChunk(
@ -2649,6 +2711,9 @@ fun EpubReaderHost(
onSearch = { text -> onSearch = { text ->
onSearchLookup(text) onSearchLookup(text)
}, },
onStartTtsFromSelection = { cfi, offset ->
startTtsFromSelectionPaginated(cfi, offset)
},
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) }, userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
onHighlightCreated = { cfi, text, colorId -> onHighlightCreated = { cfi, text, colorId ->
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")

View file

@ -31,8 +31,6 @@ import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.style.LineHeightStyle
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
@ -64,12 +62,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
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.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -125,6 +123,7 @@ import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.platform.TextToolbarStatus
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.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
@ -135,6 +134,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
@ -508,6 +508,7 @@ fun PaginatedReaderScreen(
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit, onTranslate: (String) -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onStartTtsFromSelection: (String, Int) -> Unit,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit, onHighlightDeleted: (String) -> Unit,
@ -807,6 +808,7 @@ fun PaginatedReaderScreen(
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition, onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
onTranslate = onTranslate, onTranslate = onTranslate,
onSearch = onSearch, onSearch = onSearch,
onStartTtsFromSelection = onStartTtsFromSelection,
userHighlights = userHighlights, userHighlights = userHighlights,
onHighlightCreated = onHighlightCreated, onHighlightCreated = onHighlightCreated,
onHighlightDeleted = onHighlightDeleted, onHighlightDeleted = onHighlightDeleted,
@ -1379,6 +1381,7 @@ internal fun PaginatedReaderContent(
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
onTranslate: (String) -> Unit, onTranslate: (String) -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onStartTtsFromSelection: (String, Int) -> Unit,
onGetChapterInfo: (Int) -> Pair<String, Int?>?, onGetChapterInfo: (Int) -> Pair<String, Int?>?,
userHighlights: List<UserHighlight>, userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit, onHighlightCreated: (String, String, String) -> Unit,
@ -2533,14 +2536,58 @@ internal fun PaginatedReaderContent(
}, onSelectAll = { }, onSelectAll = {
state.onSelectAll?.invoke() state.onSelectAll?.invoke()
state.onHide() state.onHide()
}, onTts = {
isForHighlight = true
state.onCopy()
isForHighlight = false
capturedTextForAction?.let { text ->
val selectionRect = state.rect
var geometricSuccess = false
val candidates = blockLayoutMap.filter { (_, triple) ->
val (_, coords, _) = triple
if (!coords.isAttached) return@filter false
val pos = coords.positionInWindow()
val size = coords.size.toSize()
Rect(pos, size).overlaps(selectionRect)
}
if (candidates.isNotEmpty()) {
try {
val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y }
val firstEntry = sorted.first()
val startCfi: String = firstEntry.key
val startTriple = firstEntry.value
val startLayout: TextLayoutResult = startTriple.first
val startCoords: LayoutCoordinates = startTriple.second
val startAbsOffset: Int = startTriple.third
val localStart = startCoords.windowToLocal(selectionRect.topLeft)
val finalStartOffset = startLayout.getOffsetForPosition(localStart)
val absStart: Int = finalStartOffset + startAbsOffset
onStartTtsFromSelection(startCfi, absStart)
geometricSuccess = true
} catch(e: Exception) {
Timber.e(e, "TTS Selection error")
}
}
if (!geometricSuccess) {
val pageInfo = onGetPage(pagerState.currentPage)
val firstCfi = pageInfo?.content?.firstOrNull { it.cfi != null }?.cfi
if (firstCfi != null) {
onStartTtsFromSelection(firstCfi, 0)
}
}
}
state.onHide()
}, onDictionary = { }, onDictionary = {
isForDictionary = true isForDictionary = true
state.onCopy() state.onCopy()
isForDictionary = false isForDictionary = false
state.onHide() state.onHide()
}, onTranslate = { }, onTranslate = {
state.onCopy() // we don't necessarily need copy to get text, but follow dictionary pattern if needed, wait menuState has selectedText! state.onCopy()
// Actually PaginatedMenuState has `selectedText`? Let's check.
onTranslate(capturedTextForAction ?: "") onTranslate(capturedTextForAction ?: "")
state.onHide() state.onHide()
}, onSearch = { }, onSearch = {
@ -2789,6 +2836,9 @@ internal fun PaginatedReaderContent(
}, onSearch = { }, onSearch = {
onSearch(sel.text) onSearch(sel.text)
activeSelection = null activeSelection = null
}, onTts = {
onStartTtsFromSelection(sel.baseCfi, sel.startOffset)
activeSelection = null
}, onHighlight = { color -> }, onHighlight = { color ->
Timber.d( Timber.d(
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
@ -2863,6 +2913,13 @@ internal fun PaginatedReaderContent(
onSearch(highlight.text) onSearch(highlight.text)
activeHighlightForMenu = null activeHighlightForMenu = null
}, },
onTts = {
val firstPart = highlight.cfi.split("|").first()
val baseCfi = firstPart.substringBefore(":")
val offset = firstPart.substringAfter(":", "0").toIntOrNull() ?: 0
onStartTtsFromSelection(baseCfi, offset)
activeHighlightForMenu = null
},
onHighlight = { color -> onHighlight = { color ->
Timber.d("Menu: Updating highlight color to ${color.id}") Timber.d("Menu: Updating highlight color to ${color.id}")
onHighlightDeleted(highlight.cfi) onHighlightDeleted(highlight.cfi)
@ -2935,8 +2992,16 @@ private fun ChapterLoadingPlaceholder(title: String?) {
} }
} }
private class MenuActionItem(
val iconRes: Int? = null,
val imageVector: androidx.compose.ui.graphics.vector.ImageVector? = null,
val label: String,
val onClick: () -> Unit,
val isError: Boolean = false
)
@Composable @Composable
private fun PaginatedTextSelectionMenu( fun PaginatedTextSelectionMenu(
onCopy: () -> Unit, onCopy: () -> Unit,
onSelectAll: (() -> Unit)?, onSelectAll: (() -> Unit)?,
onDictionary: () -> Unit, onDictionary: () -> Unit,
@ -2944,6 +3009,7 @@ private fun PaginatedTextSelectionMenu(
onSearch: () -> Unit, onSearch: () -> Unit,
onHighlight: ((HighlightColor) -> Unit)?, onHighlight: ((HighlightColor) -> Unit)?,
onDelete: (() -> Unit)?, onDelete: (() -> Unit)?,
onTts: (() -> Unit)?,
@Suppress("unused") isProUser: Boolean, @Suppress("unused") isProUser: Boolean,
@Suppress("unused") isOss: Boolean, @Suppress("unused") isOss: Boolean,
activeHighlightPalette: List<HighlightColor> = emptyList(), activeHighlightPalette: List<HighlightColor> = emptyList(),
@ -2986,69 +3052,52 @@ private fun PaginatedTextSelectionMenu(
HorizontalDivider() HorizontalDivider()
} }
// 2. Action Icons Row (Horizontal) val actions = mutableListOf<MenuActionItem>()
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = onCopy))
if (onTts != null) {
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
}
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = onDictionary))
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = onTranslate))
actions.add(MenuActionItem(iconRes = R.drawable.search, label = "Search", onClick = onSearch))
if (onSelectAll != null) {
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = onSelectAll))
}
if (onDelete != null) {
actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = onDelete, isError = true))
}
Column(modifier = Modifier.padding(bottom = 4.dp)) {
actions.chunked(3).forEach { rowActions ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onCopy) { rowActions.forEach { action ->
Icon( val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
painter = painterResource(id = R.drawable.copy), Column(
contentDescription = "Copy", modifier = Modifier
tint = MaterialTheme.colorScheme.onSurface, .width(64.dp)
modifier = Modifier.size(24.dp) .clickable { action.onClick() }
) .padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (action.imageVector != null) {
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
} else if (action.iconRes != null) {
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
} }
Spacer(modifier = Modifier.height(4.dp))
IconButton(onClick = onDictionary) { Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
IconButton(onClick = onTranslate) {
Icon(
painter = painterResource(id = R.drawable.translate),
contentDescription = "Translate",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
IconButton(onClick = onSearch) {
Icon(
painter = painterResource(id = R.drawable.search),
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
if (onSelectAll != null) {
IconButton(onClick = onSelectAll) {
Icon(
painter = painterResource(id = R.drawable.select_all),
contentDescription = "Select All",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
} }
} }
repeat(3 - rowActions.size) {
if (onDelete != null) { Spacer(modifier = Modifier.width(64.dp))
IconButton(onClick = onDelete) { }
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
)
} }
} }
} }

View file

@ -44,6 +44,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@ -58,6 +59,7 @@ 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -88,6 +90,14 @@ internal data class OcrSymbolInfo(
val parentLine: OcrLine val parentLine: OcrLine
) )
private class MenuActionItem(
val iconRes: Int? = null,
val imageVector: ImageVector? = null,
val label: String,
val onClick: () -> Unit,
val isError: Boolean = false
)
enum class PdfHighlightColor(val color: Color) { enum class PdfHighlightColor(val color: Color) {
YELLOW(Color(0xFFFBC02D)), YELLOW(Color(0xFFFBC02D)),
GREEN(Color(0xFF388E3C)), GREEN(Color(0xFF388E3C)),
@ -194,7 +204,8 @@ internal fun PdfSelectionMenuPopup(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onColorSelected: (PdfHighlightColor) -> Unit, onColorSelected: (PdfHighlightColor) -> Unit,
onDelete: () -> Unit onDelete: () -> Unit,
onTts: (() -> Unit)? = null
) { ) {
Popup( Popup(
popupPositionProvider = popupPositionProvider, popupPositionProvider = popupPositionProvider,
@ -266,94 +277,55 @@ internal fun PdfSelectionMenuPopup(
HorizontalDivider() HorizontalDivider()
val actions = mutableListOf<MenuActionItem>()
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = { onCopy(menuState.selectedText) }))
if (onTts != null) {
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
}
if (menuState.selectedText.length <= 2000) {
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = { onAiDefine(menuState.selectedText) }))
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = { onTranslate(menuState.selectedText) }))
actions.add(MenuActionItem(imageVector = Icons.Default.Search, label = "Search", onClick = { onSearch(menuState.selectedText) }))
}
if (!menuState.isExistingHighlight) {
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = { onSelectAll() }))
}
if (menuState.isExistingHighlight) { if (menuState.isExistingHighlight) {
Row(modifier = Modifier.fillMaxWidth().clickable { onDelete() } actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = { onDelete() }, isError = true))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Text(
text = "Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
} }
Column(modifier = Modifier.padding(bottom = 4.dp)) {
actions.chunked(3).forEach { rowActions ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) // Fixed height for the sleek row .padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Copy rowActions.forEach { action ->
androidx.compose.material3.IconButton( val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
onClick = { onCopy(menuState.selectedText) } Column(
modifier = Modifier
.width(64.dp)
.clickable { action.onClick() }
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( if (action.imageVector != null) {
Icons.Default.CopyAll, Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
contentDescription = "Copy", } else if (action.iconRes != null) {
modifier = Modifier.size(24.dp) Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
)
} }
Spacer(modifier = Modifier.height(4.dp))
// Dictionary Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
if (menuState.selectedText.length <= 2000) {
androidx.compose.material3.IconButton(
onClick = { onAiDefine(menuState.selectedText) }
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary",
modifier = Modifier.size(24.dp)
)
} }
} }
repeat(3 - rowActions.size) {
// Translate Spacer(modifier = Modifier.width(64.dp))
if (menuState.selectedText.length <= 2000) {
androidx.compose.material3.IconButton(
onClick = { onTranslate(menuState.selectedText) }
) {
Icon(
painter = painterResource(id = R.drawable.translate),
contentDescription = "Translate",
modifier = Modifier.size(24.dp)
)
} }
} }
// Search
if (menuState.selectedText.length <= 2000) {
androidx.compose.material3.IconButton(
onClick = { onSearch(menuState.selectedText) }
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(24.dp)
)
}
}
// Select All
if (!menuState.isExistingHighlight) {
androidx.compose.material3.IconButton(
onClick = { onSelectAll() }
) {
Icon(
painter = painterResource(id = R.drawable.select_all),
contentDescription = "Select All",
modifier = Modifier.size(24.dp)
)
}
} }
} }
} }

View file

@ -436,6 +436,7 @@ internal fun PdfPageComposable(
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {}, onHighlightDelete: (String) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
val pdfDocumentItem = pdfDocument.item val pdfDocumentItem = pdfDocument.item
@ -2192,7 +2193,7 @@ internal fun PdfPageComposable(
customMenuState = CustomPdfMenuState( customMenuState = CustomPdfMenuState(
selectedText = selectedText, selectedText = selectedText,
anchorRect = combinedRect, anchorRect = combinedRect,
charRange = Pair(-1, -1) charRange = Pair(indices.first, indices.second)
) )
Timber.d( Timber.d(
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}" "Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
@ -2395,7 +2396,7 @@ internal fun PdfPageComposable(
selectedText = foundElement.text, selectedText = foundElement.text,
anchorRect = combinedRect, anchorRect = combinedRect,
charRange = Pair( charRange = Pair(
-1, -1 symbolStartIndex, symbolEndIndex
) )
) )
Timber.d( Timber.d(
@ -3637,6 +3638,7 @@ internal fun PdfPageComposable(
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
onTts = onTts,
teardropHeightPx = teardropHeightPxState.value, teardropHeightPx = teardropHeightPxState.value,
activeDraggingHandle = activeDraggingHandle, activeDraggingHandle = activeDraggingHandle,
showMagnifier = showMagnifier, showMagnifier = showMagnifier,
@ -4587,6 +4589,7 @@ private fun PdfPageRenderer(
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit, onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
onHighlightUpdate: (String, PdfHighlightColor) -> Unit, onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
onHighlightDelete: (String) -> Unit, onHighlightDelete: (String) -> Unit,
onTts: (Int, Int) -> Unit,
) { ) {
SideEffect { SideEffect {
Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}") Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}")
@ -4989,6 +4992,10 @@ private fun PdfPageRenderer(
onHighlightDelete(menuState.highlightId) onHighlightDelete(menuState.highlightId)
} }
onMenuDismiss() onMenuDismiss()
},
onTts = {
onTts(selectionData.pageIndex, menuState.charRange.first)
onMenuDismiss()
} }
) )
} }

View file

@ -230,6 +230,7 @@ internal fun PdfVerticalReader(
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
onHighlightDelete: (String) -> Unit = {}, onHighlightDelete: (String) -> Unit = {},
onTts: (Int, Int) -> Unit = { _, _ -> },
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) } var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
@ -1501,6 +1502,7 @@ internal fun PdfVerticalReader(
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
onTts = onTts,
onTextBoxDragStart = { box, localTopLeft, touchOffset -> onTextBoxDragStart = { box, localTopLeft, touchOffset ->
val currentZoom = zoomAnimatable.value val currentZoom = zoomAnimatable.value
val panX = panXAnimatable.value val panX = panXAnimatable.value

View file

@ -2398,8 +2398,8 @@ fun PdfViewerScreen(
return false return false
} }
fun startTts(pageToReadOverride: Int? = null) { fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) {
Timber.d("TTS button clicked: Starting TTS for current page") Timber.d("TTS button clicked: Starting TTS for current page/selection")
if (pdfDocument == null || totalPages == 0) { if (pdfDocument == null || totalPages == 0) {
return return
} }
@ -2532,7 +2532,15 @@ fun PdfViewerScreen(
val processedText = preprocessTextForTts(rawPageText!!) val processedText = preprocessTextForTts(rawPageText!!)
ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts) ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts)
val chunks = splitTextIntoChunks(processedText.cleanText) val cleanStartIndex = if (startCharIndex != null && startCharIndex >= 0) {
processedText.indexMap.indexOfFirst { it >= startCharIndex }.coerceAtLeast(0)
} else {
0
}
val textToChunk = processedText.cleanText.substring(cleanStartIndex)
val chunks = splitTextIntoChunks(textToChunk)
val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
?: pdfUri.lastPathSegment ?: "PDF Document" ?: pdfUri.lastPathSegment ?: "PDF Document"
@ -2570,6 +2578,30 @@ fun PdfViewerScreen(
var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val startTtsWithPermissionCheck: (Int?, Int?) -> Unit = remember(context, activity, executeWithOcrCheck) {
{ pageOverride, startCharIndex ->
executeWithOcrCheck {
when {
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
startTts(pageOverride, startCharIndex)
}
activity?.shouldShowRequestPermissionRationale(
Manifest.permission.POST_NOTIFICATIONS
) == true -> {
showPermissionRationaleDialog = true
}
else -> {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
Timber.d("Disposing sample MediaPlayer.") Timber.d("Disposing sample MediaPlayer.")
@ -3971,6 +4003,7 @@ fun PdfViewerScreen(
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
onTwoFingerSwipe = { direction -> onTwoFingerSwipe = { direction ->
coroutineScope.launch { coroutineScope.launch {
val targetPage = val targetPage =
@ -4302,6 +4335,7 @@ fun PdfViewerScreen(
onHighlightAdd = onHighlightAdd, onHighlightAdd = onHighlightAdd,
onHighlightUpdate = onHighlightUpdate, onHighlightUpdate = onHighlightUpdate,
onHighlightDelete = onHighlightDelete, onHighlightDelete = onHighlightDelete,
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
onLinkClicked = onLinkClickedStable, onLinkClicked = onLinkClickedStable,
onInternalLinkClicked = onInternalLinkNavStable, onInternalLinkClicked = onInternalLinkNavStable,
bookmarks = bookmarksHolder, bookmarks = bookmarksHolder,
@ -5614,27 +5648,7 @@ fun PdfViewerScreen(
Timber.d("TTS button clicked: Stopping TTS") Timber.d("TTS button clicked: Stopping TTS")
ttsController.stop() ttsController.stop()
} else { } else {
executeWithOcrCheck { startTtsWithPermissionCheck(null, null)
when {
ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
startTts()
}
activity?.shouldShowRequestPermissionRationale(
Manifest.permission.POST_NOTIFICATIONS
) == true -> {
showPermissionRationaleDialog = true
}
else -> {
permissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
}
}
}
} }
}) { }) {
Icon( Icon(