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:
parent
50af284224
commit
dece09fec0
8 changed files with 428 additions and 252 deletions
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue