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

@ -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
)
}
}
}

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() {
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")

View file

@ -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<UserHighlight>,
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<String, Int?>?,
userHighlights: List<UserHighlight>,
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<HighlightColor> = 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<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))
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))
}
}
}
}

View file

@ -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<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) }))
}
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))
}
}
}
}

View file

@ -436,6 +436,7 @@ internal fun PdfPageComposable(
onHighlightAdd: (Int, Pair<Int, Int>, 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<Int, Int>, 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()
}
)
}

View file

@ -230,6 +230,7 @@ internal fun PdfVerticalReader(
onHighlightAdd: (Int, Pair<Int, Int>, 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<Offset?>(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

View file

@ -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(