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