diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index e5a7d7d..3584f19 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -1038,7 +1038,7 @@ }; window.extractTextWithCfi = function () { - const results = []; + const results =[]; const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote"); contentNodes.forEach((node) => { @@ -1058,6 +1058,73 @@ 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 = { extractAndRelayText: function () { 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 () { diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 18a8d52..c8b610e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -50,13 +50,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.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.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect 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.PopupPositionProvider import androidx.core.net.toUri -import com.aryan.reader.R +import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.json.JSONObject @@ -896,75 +890,69 @@ fun ChapterWebView( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", state.selectedText) - clipboard.setPrimaryClip(clip) - state.finishActionModeCallback() - localWebViewRef?.clearFocus() - localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) - customMenuState = null - }) { - Icon(Icons.Default.CopyAll, contentDescription = "Copy") - } - - if (state.selectedText.length <= 2000) { - IconButton(onClick = { + PaginatedTextSelectionMenu( + onCopy = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", state.selectedText) + clipboard.setPrimaryClip(clip) + state.finishActionModeCallback() + localWebViewRef?.clearFocus() + localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) + customMenuState = null + }, + onSelectAll = null, + onDictionary = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onWordSelectedForAiDefinition(textToDefine) } customMenuState = null - }) { - Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary") - } - IconButton(onClick = { + }, + onTranslate = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onTranslate(textToDefine) } customMenuState = null - }) { - Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate") - } - IconButton(onClick = { + }, + onSearch = { val textToDefine = state.selectedText if (textToDefine.isNotBlank()) { onSearch(textToDefine) } customMenuState = null - }) { - Icon(painterResource(id = R.drawable.search), contentDescription = "Search") - } - } - - if (state.isExistingHighlight && state.cfi != null) { - IconButton(onClick = { - Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'") - val highlightToDelete = userHighlights.find { h -> - h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) - } - - if (highlightToDelete != null) { - val cssClassToDelete = highlightToDelete.color.cssClass - val allCfiParts = highlightToDelete.cfi.split("|") - - allCfiParts.forEach { partCfi -> - localWebViewRef?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", - null - ) - } - - onHighlightDeleted(highlightToDelete.cfi) - } - + }, + onHighlight = null, // Highlight handles itself above in the Colors Row + onTts = { + localWebViewRef?.evaluateJavascript("javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", null) state.finishActionModeCallback() + localWebViewRef?.clearFocus() + localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null) customMenuState = null - }) { - Icon(Icons.Default.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error) - } - } + }, + onDelete = if (state.isExistingHighlight && state.cfi != null) { + { + val highlightToDelete = userHighlights.find { h -> + h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi) + } + if (highlightToDelete != null) { + val cssClassToDelete = highlightToDelete.color.cssClass + val allCfiParts = highlightToDelete.cfi.split("|") + allCfiParts.forEach { partCfi -> + localWebViewRef?.evaluateJavascript( + "javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');", + null + ) + } + onHighlightDeleted(highlightToDelete.cfi) + } + state.finishActionModeCallback() + customMenuState = null + } + } else null, + isProUser = isProUser, + isOss = isOss + ) } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 8881534..0215ad1 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -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() { if (isAutoScrollModeActive) { isAutoScrollModeActive = false @@ -1007,10 +1022,66 @@ fun EpubReaderHost( } ) - var showPermissionRationaleDialog by remember { mutableStateOf(false) } - val isDarkTheme = isSystemInDarkTheme() - var showTtsSettingsSheet by remember { mutableStateOf(false) } - var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } + fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { + val action = { + scope.launch { + 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( 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) { if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) { val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage) @@ -2250,10 +2311,11 @@ fun EpubReaderHost( val cfiJsonObject = JSONObject(cfiJsonString) val cfi = cfiJsonObject.getString("cfi") + val baseOffset = jsonObject.optInt("startOffset", 0) val subChunks = splitTextIntoChunks(text) - var currentOffset = 0 + var currentOffset = baseOffset for (subChunk in subChunks) { ttsChunks.add( TtsChunk( @@ -2649,6 +2711,9 @@ fun EpubReaderHost( onSearch = { text -> onSearchLookup(text) }, + onStartTtsFromSelection = { cfi, offset -> + startTtsFromSelectionPaginated(cfi, offset) + }, userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) }, onHighlightCreated = { cfi, text, colorId -> Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi") diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index d8472d8..05ccf4e 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -31,8 +31,6 @@ import android.widget.Toast import androidx.annotation.RequiresApi import androidx.compose.foundation.BorderStroke 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.border 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.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.res.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult 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.rememberTextMeasurer 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.TextIndent import androidx.compose.ui.unit.Constraints @@ -508,6 +508,7 @@ fun PaginatedReaderScreen( onWordSelectedForAiDefinition: (String) -> Unit, onTranslate: (String) -> Unit, onSearch: (String) -> Unit, + onStartTtsFromSelection: (String, Int) -> Unit, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, onHighlightDeleted: (String) -> Unit, @@ -807,6 +808,7 @@ fun PaginatedReaderScreen( onWordSelectedForAiDefinition = onWordSelectedForAiDefinition, onTranslate = onTranslate, onSearch = onSearch, + onStartTtsFromSelection = onStartTtsFromSelection, userHighlights = userHighlights, onHighlightCreated = onHighlightCreated, onHighlightDeleted = onHighlightDeleted, @@ -1379,6 +1381,7 @@ internal fun PaginatedReaderContent( onWordSelectedForAiDefinition: (String) -> Unit, onTranslate: (String) -> Unit, onSearch: (String) -> Unit, + onStartTtsFromSelection: (String, Int) -> Unit, onGetChapterInfo: (Int) -> Pair?, userHighlights: List, onHighlightCreated: (String, String, String) -> Unit, @@ -2533,14 +2536,58 @@ internal fun PaginatedReaderContent( }, onSelectAll = { state.onSelectAll?.invoke() 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 = { isForDictionary = true state.onCopy() isForDictionary = false state.onHide() }, onTranslate = { - state.onCopy() // we don't necessarily need copy to get text, but follow dictionary pattern if needed, wait menuState has selectedText! - // Actually PaginatedMenuState has `selectedText`? Let's check. + state.onCopy() onTranslate(capturedTextForAction ?: "") state.onHide() }, onSearch = { @@ -2789,6 +2836,9 @@ internal fun PaginatedReaderContent( }, onSearch = { onSearch(sel.text) activeSelection = null + }, onTts = { + onStartTtsFromSelection(sel.baseCfi, sel.startOffset) + activeSelection = null }, onHighlight = { color -> Timber.d( "CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}" @@ -2863,6 +2913,13 @@ internal fun PaginatedReaderContent( onSearch(highlight.text) 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 -> Timber.d("Menu: Updating highlight color to ${color.id}") 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 -private fun PaginatedTextSelectionMenu( +fun PaginatedTextSelectionMenu( onCopy: () -> Unit, onSelectAll: (() -> Unit)?, onDictionary: () -> Unit, @@ -2944,6 +3009,7 @@ private fun PaginatedTextSelectionMenu( onSearch: () -> Unit, onHighlight: ((HighlightColor) -> Unit)?, onDelete: (() -> Unit)?, + onTts: (() -> Unit)?, @Suppress("unused") isProUser: Boolean, @Suppress("unused") isOss: Boolean, activeHighlightPalette: List = emptyList(), @@ -2986,69 +3052,52 @@ private fun PaginatedTextSelectionMenu( HorizontalDivider() } - // 2. Action Icons Row (Horizontal) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onCopy) { - Icon( - painter = painterResource(id = R.drawable.copy), - contentDescription = "Copy", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp) - ) - } + val actions = mutableListOf() + 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)) - IconButton(onClick = onDictionary) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = "Dictionary", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp) - ) - } + 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)) + } - 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) - ) - } - } - - if (onDelete != null) { - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp) - ) + Column(modifier = Modifier.padding(bottom = 4.dp)) { + actions.chunked(3).forEach { rowActions -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + rowActions.forEach { action -> + val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + Column( + modifier = Modifier + .width(64.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)) + Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1) + } + } + repeat(3 - rowActions.size) { + Spacer(modifier = Modifier.width(64.dp)) + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index f25d97b..845e5c2 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll 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.Delete 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -88,6 +90,14 @@ internal data class OcrSymbolInfo( 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) { YELLOW(Color(0xFFFBC02D)), GREEN(Color(0xFF388E3C)), @@ -194,7 +204,8 @@ internal fun PdfSelectionMenuPopup( onSearch: (String) -> Unit, onSelectAll: () -> Unit, onColorSelected: (PdfHighlightColor) -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onTts: (() -> Unit)? = null ) { Popup( popupPositionProvider = popupPositionProvider, @@ -266,93 +277,54 @@ internal fun PdfSelectionMenuPopup( HorizontalDivider() - if (menuState.isExistingHighlight) { - Row(modifier = Modifier.fillMaxWidth().clickable { onDelete() } - .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() + val actions = mutableListOf() + 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) })) } - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) // Fixed height for the sleek row - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Copy - androidx.compose.material3.IconButton( - onClick = { onCopy(menuState.selectedText) } - ) { - Icon( - Icons.Default.CopyAll, - contentDescription = "Copy", - modifier = Modifier.size(24.dp) - ) - } - // Dictionary - 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) - ) - } - } + if (!menuState.isExistingHighlight) { + actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = { onSelectAll() })) + } + if (menuState.isExistingHighlight) { + actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = { onDelete() }, isError = true)) + } - // Translate - if (menuState.selectedText.length <= 2000) { - androidx.compose.material3.IconButton( - onClick = { onTranslate(menuState.selectedText) } + Column(modifier = Modifier.padding(bottom = 4.dp)) { + actions.chunked(3).forEach { rowActions -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - 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) - ) + rowActions.forEach { action -> + val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + Column( + modifier = Modifier + .width(64.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)) + Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1) + } + } + repeat(3 - rowActions.size) { + Spacer(modifier = Modifier.width(64.dp)) + } } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index bd16341..057c6ab 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -436,6 +436,7 @@ internal fun PdfPageComposable( onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightDelete: (String) -> Unit = {}, + onTts: (Int, Int) -> Unit = { _, _ -> }, ) { SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") } val pdfDocumentItem = pdfDocument.item @@ -2192,7 +2193,7 @@ internal fun PdfPageComposable( customMenuState = CustomPdfMenuState( selectedText = selectedText, anchorRect = combinedRect, - charRange = Pair(-1, -1) + charRange = Pair(indices.first, indices.second) ) Timber.d( "Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}" @@ -2395,7 +2396,7 @@ internal fun PdfPageComposable( selectedText = foundElement.text, anchorRect = combinedRect, charRange = Pair( - -1, -1 + symbolStartIndex, symbolEndIndex ) ) Timber.d( @@ -3637,6 +3638,7 @@ internal fun PdfPageComposable( onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, + onTts = onTts, teardropHeightPx = teardropHeightPxState.value, activeDraggingHandle = activeDraggingHandle, showMagnifier = showMagnifier, @@ -4587,6 +4589,7 @@ private fun PdfPageRenderer( onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit, onHighlightUpdate: (String, PdfHighlightColor) -> Unit, onHighlightDelete: (String) -> Unit, + onTts: (Int, Int) -> Unit, ) { SideEffect { Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}") @@ -4989,6 +4992,10 @@ private fun PdfPageRenderer( onHighlightDelete(menuState.highlightId) } onMenuDismiss() + }, + onTts = { + onTts(selectionData.pageIndex, menuState.charRange.first) + onMenuDismiss() } ) } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt index 7a3a6f5..d372e05 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -230,6 +230,7 @@ internal fun PdfVerticalReader( onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> }, onHighlightDelete: (String) -> Unit = {}, + onTts: (Int, Int) -> Unit = { _, _ -> }, ) { SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } var globalEraserPosition by remember { mutableStateOf(null) } @@ -1501,6 +1502,7 @@ internal fun PdfVerticalReader( onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, + onTts = onTts, onTextBoxDragStart = { box, localTopLeft, touchOffset -> val currentZoom = zoomAnimatable.value val panX = panXAnimatable.value diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index f7579f3..8ef6b55 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -2398,8 +2398,8 @@ fun PdfViewerScreen( return false } - fun startTts(pageToReadOverride: Int? = null) { - Timber.d("TTS button clicked: Starting TTS for current page") + fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) { + Timber.d("TTS button clicked: Starting TTS for current page/selection") if (pdfDocument == null || totalPages == 0) { return } @@ -2532,7 +2532,15 @@ fun PdfViewerScreen( val processedText = preprocessTextForTts(rawPageText!!) 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() } ?: pdfUri.lastPathSegment ?: "PDF Document" @@ -2570,6 +2578,30 @@ fun PdfViewerScreen( 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) { onDispose { Timber.d("Disposing sample MediaPlayer.") @@ -3971,6 +4003,7 @@ fun PdfViewerScreen( onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, + onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, onTwoFingerSwipe = { direction -> coroutineScope.launch { val targetPage = @@ -4302,6 +4335,7 @@ fun PdfViewerScreen( onHighlightAdd = onHighlightAdd, onHighlightUpdate = onHighlightUpdate, onHighlightDelete = onHighlightDelete, + onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) }, onLinkClicked = onLinkClickedStable, onInternalLinkClicked = onInternalLinkNavStable, bookmarks = bookmarksHolder, @@ -5614,27 +5648,7 @@ fun PdfViewerScreen( Timber.d("TTS button clicked: Stopping TTS") ttsController.stop() } else { - executeWithOcrCheck { - 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 - ) - } - } - } + startTtsWithPermissionCheck(null, null) } }) { Icon(