Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
966
app/src/main/java/com/aryan/reader/Common.kt
Normal file
966
app/src/main/java/com/aryan/reader/Common.kt
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
// Common.kt
|
||||
package com.aryan.reader
|
||||
|
||||
import android.content.Context
|
||||
import timber.log.Timber
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
import com.aryan.reader.tts.rememberTtsController
|
||||
import com.aryan.reader.tts.splitTextIntoChunks
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import org.commonmark.node.AbstractVisitor
|
||||
import org.commonmark.node.Code
|
||||
import org.commonmark.node.Emphasis
|
||||
import org.commonmark.node.HardLineBreak
|
||||
import org.commonmark.node.Heading
|
||||
import org.commonmark.node.ListItem
|
||||
import org.commonmark.node.Paragraph
|
||||
import org.commonmark.node.SoftLineBreak
|
||||
import org.commonmark.node.StrongEmphasis
|
||||
import org.commonmark.node.Text
|
||||
import org.commonmark.parser.Parser
|
||||
import java.io.File
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
|
||||
const val summarizeEndpoint = "/summarize"
|
||||
const val summarizationUrl = aiServerBasePath + summarizeEndpoint
|
||||
const val defineEndpoint = "/define"
|
||||
const val aiDefinitionUrl = aiServerBasePath + defineEndpoint
|
||||
const val recapEndpoint = "/recap"
|
||||
const val recapUrl = aiServerBasePath + recapEndpoint
|
||||
|
||||
data class SearchResult(
|
||||
val locationInSource: Int,
|
||||
val locationTitle: String,
|
||||
val snippet: AnnotatedString,
|
||||
val query: String,
|
||||
val occurrenceIndexInLocation: Int,
|
||||
val chunkIndex: Int
|
||||
)
|
||||
|
||||
data class AiDefinitionResult(
|
||||
val definition: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
data class SummarizationResult(
|
||||
val summary: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@Stable
|
||||
class SearchState(
|
||||
private val scope: CoroutineScope,
|
||||
private val searcher: suspend (String) -> List<SearchResult>
|
||||
) {
|
||||
var isSearchActive by mutableStateOf(false)
|
||||
var showSearchResultsPanel by mutableStateOf(true)
|
||||
var searchQuery by mutableStateOf("")
|
||||
var searchResults by mutableStateOf<List<SearchResult>>(emptyList())
|
||||
var isSearchInProgress by mutableStateOf(false)
|
||||
var currentSearchResultIndex by mutableIntStateOf(-1)
|
||||
|
||||
val searchResultsCount by derivedStateOf { searchResults.size }
|
||||
val hasResults by derivedStateOf { searchResults.isNotEmpty() }
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
fun onQueryChange(newQuery: String) {
|
||||
searchQuery = newQuery
|
||||
searchJob?.cancel()
|
||||
searchJob = scope.launch {
|
||||
if (newQuery.isBlank()) {
|
||||
searchResults = emptyList()
|
||||
currentSearchResultIndex = -1
|
||||
isSearchInProgress = false
|
||||
return@launch
|
||||
}
|
||||
delay(350)
|
||||
showSearchResultsPanel = true
|
||||
isSearchInProgress = true
|
||||
currentSearchResultIndex = -1
|
||||
searchResults = searcher(newQuery)
|
||||
isSearchInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
fun forceSearch() {
|
||||
searchJob?.cancel()
|
||||
searchJob = scope.launch {
|
||||
if (searchQuery.isBlank()) {
|
||||
searchResults = emptyList()
|
||||
currentSearchResultIndex = -1
|
||||
isSearchInProgress = false
|
||||
return@launch
|
||||
}
|
||||
showSearchResultsPanel = true
|
||||
isSearchInProgress = true
|
||||
currentSearchResultIndex = -1
|
||||
searchResults = searcher(searchQuery)
|
||||
isSearchInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSearchState(
|
||||
scope: CoroutineScope,
|
||||
searcher: suspend (String) -> List<SearchResult>
|
||||
): SearchState {
|
||||
return remember {
|
||||
SearchState(scope, searcher)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchTopBar(
|
||||
searchState: SearchState,
|
||||
focusRequester: FocusRequester,
|
||||
onCloseSearch: () -> Unit
|
||||
) {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onCloseSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Close Search"
|
||||
)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = searchState.searchQuery,
|
||||
onValueChange = { searchState.onQueryChange(it) },
|
||||
placeholder = { Text("Search in book...") },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
.testTag("SearchTextField"),
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {
|
||||
searchState.forceSearch()
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
)
|
||||
|
||||
if (searchState.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchState.onQueryChange("") }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear Search"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel
|
||||
focusManager.clearFocus()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown,
|
||||
contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchNavigationControls(
|
||||
searchState: SearchState,
|
||||
onNavigate: (Int) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 6.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onNavigate(searchState.currentSearchResultIndex - 1) },
|
||||
enabled = searchState.currentSearchResultIndex > 0
|
||||
) {
|
||||
Icon(Icons.Default.ArrowDropUp, contentDescription = "Previous Search Result")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${searchState.currentSearchResultIndex + 1}/${searchState.searchResultsCount}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { onNavigate(searchState.currentSearchResultIndex + 1) },
|
||||
enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1
|
||||
) {
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "Next Search Result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun SummarizationPopup(
|
||||
title: String,
|
||||
result: SummarizationResult?,
|
||||
isLoading: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isMainTtsActive: Boolean = false,
|
||||
) {
|
||||
val ttsController = rememberTtsController()
|
||||
val ttsState by ttsController.ttsState.collectAsState()
|
||||
LocalContext.current
|
||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) {
|
||||
ttsController.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.Center,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||
.heightIn(min = 150.dp, max = 500.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(all = 20.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
if (isLoading && (result?.summary.isNullOrBlank() && result?.error.isNullOrBlank())) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text("Generating summary...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else if (result != null) {
|
||||
val summaryText = result.summary
|
||||
val errorText = result.error
|
||||
|
||||
val styledContent = remember(summaryText, errorText) {
|
||||
if (!summaryText.isNullOrBlank()) {
|
||||
MarkdownParser.parse(summaryText)
|
||||
} else {
|
||||
AnnotatedString(errorText ?: "")
|
||||
}
|
||||
}
|
||||
val textToUse = styledContent.text
|
||||
|
||||
if (textToUse.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isTtsSessionActive) {
|
||||
ttsController.stop()
|
||||
} else {
|
||||
val chunks = splitTextIntoChunks(textToUse).map {
|
||||
TtsChunk(it, "", -1)
|
||||
}
|
||||
if (chunks.isNotEmpty()) {
|
||||
ttsController.start(
|
||||
chunks = chunks,
|
||||
bookTitle = title,
|
||||
chapterTitle = "Summary",
|
||||
coverImageUri = null,
|
||||
ttsMode = "BASE",
|
||||
playbackSource = "POPUP"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP")
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(textToUse))
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
if (errorText != null && summaryText.isNullOrBlank()) {
|
||||
Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge)
|
||||
} else if (textToUse.isNotBlank()) {
|
||||
val scrollState = rememberScrollState()
|
||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
|
||||
LaunchedEffect(ttsState.currentText, textLayoutResult) {
|
||||
val currentChunk = ttsState.currentText
|
||||
val layoutResult = textLayoutResult
|
||||
if (!currentChunk.isNullOrBlank() && layoutResult != null) {
|
||||
val startIndex = textToUse.indexOf(currentChunk)
|
||||
if (startIndex != -1) {
|
||||
val line = layoutResult.getLineForOffset(startIndex)
|
||||
val lineTop = layoutResult.getLineTop(line)
|
||||
val viewportHeight = scrollState.viewportSize
|
||||
val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f)
|
||||
scope.launch {
|
||||
scrollState.animateScrollTo(targetScroll.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
append(styledContent)
|
||||
val currentChunk = ttsState.currentText
|
||||
if (!currentChunk.isNullOrBlank()) {
|
||||
val startIndex = textToUse.indexOf(currentChunk)
|
||||
if (startIndex != -1) {
|
||||
addStyle(
|
||||
style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer),
|
||||
start = startIndex,
|
||||
end = startIndex + currentChunk.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
onTextLayout = { textLayoutResult = it }
|
||||
)
|
||||
} else {
|
||||
Text("No summary could be generated.", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun AiDefinitionPopup(
|
||||
word: String?,
|
||||
result: AiDefinitionResult?,
|
||||
isLoading: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isMainTtsActive: Boolean = false
|
||||
) {
|
||||
val ttsController = rememberTtsController()
|
||||
val ttsState by ttsController.ttsState.collectAsState()
|
||||
LocalContext.current
|
||||
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) {
|
||||
ttsController.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 5.dp)
|
||||
.heightIn(min = 150.dp, max = 400.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(all = 20.dp)) {
|
||||
if (isLoading && (result?.definition.isNullOrBlank() && result?.error.isNullOrBlank())) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text("Thinking...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else if (result != null) {
|
||||
word?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val definitionText = result.definition
|
||||
val errorText = result.error
|
||||
|
||||
val styledContent = remember(definitionText, errorText) {
|
||||
if (!definitionText.isNullOrBlank()) {
|
||||
MarkdownParser.parse(definitionText)
|
||||
} else {
|
||||
AnnotatedString(errorText ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
val textToUse = styledContent.text
|
||||
|
||||
if (textToUse.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
definitionText ?: errorText ?: ""
|
||||
val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isTtsSessionActive) {
|
||||
ttsController.stop()
|
||||
} else {
|
||||
val chunks = splitTextIntoChunks(textToUse).map {
|
||||
TtsChunk(it, "", -1)
|
||||
}
|
||||
if (chunks.isNotEmpty()) {
|
||||
ttsController.start(
|
||||
chunks = chunks,
|
||||
bookTitle = "AI Definition",
|
||||
chapterTitle = word,
|
||||
coverImageUri = null,
|
||||
ttsMode = "BASE",
|
||||
playbackSource = "POPUP"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP")
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(textToUse))
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
if (errorText != null && definitionText.isNullOrBlank()) {
|
||||
Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge)
|
||||
} else if (textToUse.isNotBlank()) {
|
||||
val scrollState = rememberScrollState()
|
||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
|
||||
LaunchedEffect(ttsState.currentText, textLayoutResult) {
|
||||
val currentChunk = ttsState.currentText
|
||||
val layoutResult = textLayoutResult
|
||||
if (!currentChunk.isNullOrBlank() && layoutResult != null) {
|
||||
val startIndex = textToUse.indexOf(currentChunk)
|
||||
if (startIndex != -1) {
|
||||
val line = layoutResult.getLineForOffset(startIndex)
|
||||
val lineTop = layoutResult.getLineTop(line)
|
||||
val viewportHeight = scrollState.viewportSize
|
||||
val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f)
|
||||
scope.launch {
|
||||
scrollState.animateScrollTo(targetScroll.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
append(styledContent)
|
||||
val currentChunk = ttsState.currentText
|
||||
if (!currentChunk.isNullOrBlank()) {
|
||||
val startIndex = textToUse.indexOf(currentChunk)
|
||||
if (startIndex != -1) {
|
||||
addStyle(
|
||||
style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer),
|
||||
start = startIndex,
|
||||
end = startIndex + currentChunk.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
onTextLayout = { textLayoutResult = it }
|
||||
)
|
||||
} else {
|
||||
Text("AI could not provide a definition.", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else if (word != null) {
|
||||
Text(
|
||||
text = "Asking AI about '$word'...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchResultsPanel(
|
||||
results: List<SearchResult>,
|
||||
isSearching: Boolean,
|
||||
onResultClick: (SearchResult) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
when {
|
||||
isSearching -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
results.isEmpty() -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Column {
|
||||
Text(
|
||||
text = "${results.size} " + if (results.size == 1) "result found" else "results found",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
HorizontalDivider()
|
||||
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
|
||||
items(results.size) { index ->
|
||||
val result = results[index]
|
||||
ListItem(
|
||||
headlineContent = { Text(result.locationTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = { Text(result.snippet, style = MaterialTheme.typography.bodyMedium) },
|
||||
modifier = Modifier
|
||||
.clickable { onResultClick(result) }
|
||||
.testTag("SearchResultItem_${result.locationInSource}")
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAiDefinition(
|
||||
text: String,
|
||||
onUpdate: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
if (text.isBlank()) {
|
||||
onError("Text is empty.")
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
Timber.d("Fetching AI definition for: '$text'")
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
val url = URL(aiDefinitionUrl)
|
||||
connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.connectTimeout = 10000
|
||||
connection.readTimeout = 30000
|
||||
connection.doOutput = true
|
||||
connection.doInput = true
|
||||
|
||||
val jsonPayload = JSONObject().apply { put("text", text) }
|
||||
connection.outputStream.use { os ->
|
||||
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
Timber.d("Definition: Got response code $responseCode")
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
var hasReceivedData = false
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Timber.d("Definition: Received line: $line")
|
||||
try {
|
||||
val jsonResponse = JSONObject(line!!)
|
||||
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
|
||||
Timber.d("Definition: Parsed chunk, calling onUpdate.")
|
||||
onUpdate(it)
|
||||
hasReceivedData = true
|
||||
}
|
||||
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
|
||||
onError(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not parse stream line: $line")
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.d("Definition: Finished reading stream.")
|
||||
if (!hasReceivedData) {
|
||||
onError("AI returned an empty definition.")
|
||||
}
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." }
|
||||
onError("Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Network error fetching AI definition: ${e.message}")
|
||||
onError("Network error. Check connection.")
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun countWords(text: String): Int {
|
||||
return text.trim().split(Regex("\\s+")).filter { it.isNotBlank() }.size
|
||||
}
|
||||
|
||||
object MarkdownParser {
|
||||
fun parse(markdown: String): AnnotatedString {
|
||||
val parser = Parser.builder().build()
|
||||
val document = parser.parse(markdown)
|
||||
val builder = AnnotatedString.Builder()
|
||||
|
||||
val visitor = object : AbstractVisitor() {
|
||||
override fun visit(text: Text) {
|
||||
builder.append(text.literal)
|
||||
}
|
||||
|
||||
override fun visit(emphasis: Emphasis) {
|
||||
builder.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
|
||||
visitChildren(emphasis)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
override fun visit(strongEmphasis: StrongEmphasis) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
visitChildren(strongEmphasis)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
override fun visit(paragraph: Paragraph) {
|
||||
visitChildren(paragraph)
|
||||
// Add newline if it's not the last node
|
||||
if (paragraph.next != null) {
|
||||
builder.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
override fun visit(heading: Heading) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
visitChildren(heading)
|
||||
builder.pop()
|
||||
builder.append("\n\n")
|
||||
}
|
||||
|
||||
override fun visit(softLineBreak: SoftLineBreak) {
|
||||
builder.append(" ")
|
||||
}
|
||||
|
||||
override fun visit(hardLineBreak: HardLineBreak) {
|
||||
builder.append("\n")
|
||||
}
|
||||
|
||||
override fun visit(code: Code) {
|
||||
builder.pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color(0x22888888)))
|
||||
builder.append(code.literal)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
override fun visit(listItem: ListItem) {
|
||||
builder.append("• ")
|
||||
visitChildren(listItem)
|
||||
if (listItem.next != null) {
|
||||
builder.append("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.accept(visitor)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
}
|
||||
|
||||
class SummaryCacheManager(context: Context) {
|
||||
private val cacheDir = File(context.cacheDir, "chapter_summaries")
|
||||
|
||||
init {
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileName(bookTitle: String, chapterIndex: Int): String {
|
||||
// Sanitize title to be file-system safe
|
||||
val safeTitle = bookTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_")
|
||||
return "summary_${safeTitle}_$chapterIndex.txt"
|
||||
}
|
||||
|
||||
fun saveSummary(bookTitle: String, chapterIndex: Int, summary: String) {
|
||||
try {
|
||||
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
|
||||
file.writeText(summary)
|
||||
Timber.d("Saved summary for $bookTitle Ch $chapterIndex")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to save summary")
|
||||
}
|
||||
}
|
||||
|
||||
fun getSummary(bookTitle: String, chapterIndex: Int): String? {
|
||||
return try {
|
||||
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
|
||||
if (file.exists()) file.readText() else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun hasSummary(bookTitle: String, chapterIndex: Int): Boolean {
|
||||
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
|
||||
return file.exists()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchRecap(
|
||||
pastSummaries: List<String>,
|
||||
currentText: String,
|
||||
onUpdate: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
if (pastSummaries.isEmpty() && currentText.isBlank()) {
|
||||
onError("Not enough context for a recap.")
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
val url = URL(recapUrl)
|
||||
connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "POST"
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 120000
|
||||
connection.doOutput = true
|
||||
connection.doInput = true
|
||||
|
||||
val jsonPayload = JSONObject().apply {
|
||||
put("past_summaries", org.json.JSONArray(pastSummaries))
|
||||
put("current_text", currentText)
|
||||
}
|
||||
|
||||
connection.outputStream.use { os ->
|
||||
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
var hasReceivedData = false
|
||||
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
try {
|
||||
val jsonResponse = JSONObject(line!!)
|
||||
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
|
||||
onUpdate(it)
|
||||
hasReceivedData = true
|
||||
}
|
||||
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
|
||||
onError(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not parse stream line: $line")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasReceivedData) onError("Failed to parse recap.")
|
||||
} else {
|
||||
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
|
||||
onError("Error: $responseCode. ${errorBody ?: ""}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Recap error: ${e.message}")
|
||||
onError("Network error during recap generation.")
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue