diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa48d29..7015c5d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 43 - versionName = "1.0.42" + versionCode = 44 + versionName = "1.0.43" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 0cdc0a2..9b907b7 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -581,25 +581,22 @@ var scrollHeight = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)); var clientHeight = Math.round(document.documentElement.clientHeight || window.innerHeight || 0); - if (clientHeight === 0) return; + if (clientHeight === 0) return; - var activeFragment = null; - var hasFoundAnyElementInDom = false; + var activeFragment = null; + var hasFoundVisible = false; - if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { - // Adjust threshold to be slightly more forgiving (padding + 60px) + if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { var threshold = window.VIEWPORT_PADDING_TOP + 60; for (var i = 0; i < window.TOC_FRAGMENTS.length; i++) { var id = window.TOC_FRAGMENTS[i]; - // FIX: Look for both 'id' and 'name' attributes var el = document.getElementById(id) || document.querySelector('[name="' + id + '"]'); if (el) { hasFoundVisible = true; var rect = el.getBoundingClientRect(); - // Log individual element positions so we can see them in your FRAG_NAV_DEBUG filter console.log("FRAG_NAV_DEBUG: Checking #" + id + " | rect.top: " + Math.round(rect.top) + " | threshold: " + threshold); if (rect.top <= threshold) { @@ -660,8 +657,24 @@ ); }; - window.addEventListener("scroll", window.reportScrollState, { passive: true }); - window.addEventListener("resize", window.reportScrollState); + let scrollThrottleTimeout = null; + let lastScrollTime = 0; + + window.addEventListener("scroll", function() { + const now = Date.now(); + if (now - lastScrollTime >= 100) { + window.reportScrollState(); + lastScrollTime = now; + } else { + if (scrollThrottleTimeout) clearTimeout(scrollThrottleTimeout); + scrollThrottleTimeout = setTimeout(function() { + window.reportScrollState(); + lastScrollTime = Date.now(); + }, 100); + } + }, { passive: true }); + + window.addEventListener("resize", window.reportScrollState); window.triggerInitialScrollStateReport = function () { var attempts = 0; @@ -896,12 +909,8 @@ } try { - console.log(`$ { - TTS_HIGHLIGHT_LOG_TAG - } - - : Resolving CFI to node...`); - const location = window.getNodeAndOffsetFromCfi(cfi); + console.log(`${TTS_HIGHLIGHT_LOG_TAG}: Resolving CFI to node...`); + const location = window.getNodeAndOffsetFromCfi(cfi, true); if (!location || !location.node) { const errorMsg = "JS: Could not find node for CFI."; @@ -1404,7 +1413,7 @@ `); } - function resolveCfiPath(rootElement, path) { + function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) { let currentNode = rootElement; const steps = path.substring(1).split("/").map(Number); @@ -1420,12 +1429,26 @@ let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); if (chunkElement) { - if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { - console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution."); - chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex]; - chunkElement.style.height = ""; - if (window.CURRENT_HIGHLIGHTS) { - window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); + if (chunkElement.innerHTML === "") { + if (window.virtualization && window.virtualization.chunksData[chunkIndex]) { + console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution."); + chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex]; + chunkElement.style.height = ""; + if (window.CURRENT_HIGHLIGHTS) { + window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); + } + } else { + if (requestChunkIfMissing) { + console.log("PosSaveDiag: Requesting missing chunk " + chunkIndex + " for CFI resolution."); + if (window.ContentBridge && window.ContentBridge.requestChunk) { + if (!window._requestedChunksForCfi) window._requestedChunksForCfi = {}; + if (!window._requestedChunksForCfi[chunkIndex]) { + window._requestedChunksForCfi[chunkIndex] = true; + window.ContentBridge.requestChunk(chunkIndex); + } + } + } + return null; } } @@ -1450,7 +1473,7 @@ return currentNode; } - window.getNodeAndOffsetFromCfi = function (cfi) { + window.getNodeAndOffsetFromCfi = function (cfi, requestChunkIfMissing = false) { try { var pathParts = cfi.split(":"); var nodePath = pathParts[0]; @@ -1470,7 +1493,7 @@ return { node: cfiRoot, offset: charOffset }; } - let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve); + let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve, requestChunkIfMissing); if (!resolvedNode) return null; @@ -1650,6 +1673,7 @@ } console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi); + window._requestedChunksForCfi = {}; // Reset the requested cache if (!cleanCfi || !cleanCfi.startsWith('/')) { if (window.CfiBridge && window.CfiBridge.onScrollFinished) { @@ -1659,7 +1683,7 @@ } let attempts = 0; - const maxAttempts = 20; + const maxAttempts = 40; // Increased to 40 attempts (4 seconds) to give Kotlin time to inject chunks let stabilizingFrames = 0; const maxStabilizingFrames = 8; @@ -1667,7 +1691,8 @@ attempts++; try { - const location = window.getNodeAndOffsetFromCfi(cleanCfi); + // Pass 'true' to dynamically request any missing chunks needed to resolve the position + const location = window.getNodeAndOffsetFromCfi(cleanCfi, true); if (location && location.node) { if (!document.body.contains(location.node)) { @@ -1700,9 +1725,24 @@ const range = document.createRange(); const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length); - range.setStart(currentNode, validOffset); - range.collapse(true); - const rect = range.getBoundingClientRect(); + + const endOffset = Math.min(validOffset + 1, currentNode.nodeValue.length); + if (validOffset < endOffset) { + range.setStart(currentNode, validOffset); + range.setEnd(currentNode, endOffset); + } else if (validOffset > 0) { + range.setStart(currentNode, validOffset - 1); + range.setEnd(currentNode, validOffset); + } else { + range.setStart(currentNode, validOffset); + range.collapse(true); + } + + let rect = range.getBoundingClientRect(); + const rects = range.getClientRects(); + if (rects && rects.length > 0) { + rect = rects[0]; + } if (rect.top !== 0 || rect.bottom !== 0) { targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5); @@ -1739,12 +1779,14 @@ if (attempts < maxAttempts) { setTimeout(attemptScroll, 100); } else { + console.log("PosSaveDiag: attemptScroll failed after " + maxAttempts + " attempts for CFI: " + cleanCfi); if (window.CfiBridge && window.CfiBridge.onScrollFinished) { window.CfiBridge.onScrollFinished(false); } } } } catch (e) { + console.log("PosSaveDiag: attemptScroll exception: " + e.message); if (window.CfiBridge && window.CfiBridge.onScrollFinished) { window.CfiBridge.onScrollFinished(false); } diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index f51b359..cbc138b 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -72,30 +72,35 @@ fun AppNavigation( LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { if (!uiState.isLoading) { - when (uiState.selectedFileType) { - FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { - if (uiState.selectedPdfUri != null) { - if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { - navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { - popUpTo(AppDestinations.MAIN_ROUTE) + try { + when (uiState.selectedFileType) { + FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { + if (uiState.selectedPdfUri != null) { + if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { + navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { + popUpTo(AppDestinations.MAIN_ROUTE) + } } } } - } - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { - if (uiState.selectedEpubBook != null) { - if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { - navController.navigate(AppDestinations.EPUB_READER_ROUTE) { - popUpTo(AppDestinations.MAIN_ROUTE) + FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { + if (uiState.selectedEpubBook != null) { + if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { + navController.navigate(AppDestinations.EPUB_READER_ROUTE) { + popUpTo(AppDestinations.MAIN_ROUTE) + } } } } - } - null -> { - if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) { - navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false) + null -> { + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) { + navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false) + } } } + } catch (e: IllegalStateException) { + Timber.w(e, "Navigation transition already in progress, ignoring.") } } } diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt index 1252597..b709193 100644 --- a/app/src/main/java/com/aryan/reader/Common.kt +++ b/app/src/main/java/com/aryan/reader/Common.kt @@ -1,5 +1,5 @@ // Common.kt -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class) @file:Suppress("KotlinConstantConditions") package com.aryan.reader @@ -35,8 +35,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -55,15 +55,15 @@ import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -73,16 +73,19 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.RichTooltip -import androidx.compose.material3.Slider import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable @@ -105,6 +108,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -120,7 +124,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.imageResource @@ -140,6 +143,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.core.content.edit @@ -149,9 +154,11 @@ import com.aryan.reader.epubreader.PREF_CUSTOM_THEMES import com.aryan.reader.epubreader.PREF_READER_THEME import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.pdf.PdfHighlightColor -import com.aryan.reader.tts.GOOGLE_TTS_SPEAKERS +import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS import com.aryan.reader.tts.SpeakerSamplePlayer +import com.aryan.reader.tts.TtsCacheManager import com.aryan.reader.tts.TtsPlaybackManager +import com.aryan.reader.tts.formatBytes import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks @@ -208,9 +215,94 @@ data class AiDefinitionResult( data class SummarizationResult( val summary: String? = null, - val error: String? = null + val error: String? = null, + val cost: Double? = null, + val freeRemaining: Int? = null, + val isCacheHit: Boolean = false ) +data class CachedSummaryItem( + val chapterIndex: Int, + val chapterTitle: String, + val summary: String, + val file: File +) + +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 { + val safeTitle = bookTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_") + return "summary_${safeTitle}_$chapterIndex.txt" + } + + fun saveSummary(bookTitle: String, chapterIndex: Int, chapterTitle: String, summary: String) { + try { + val file = File(cacheDir, getFileName(bookTitle, chapterIndex)) + val contentToSave = "$chapterTitle\n$summary" + file.writeText(contentToSave) + Timber.d("Saved summary with title 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() + } + + fun getAllSummaries(bookTitle: String): List { + val safeTitle = bookTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_") + val prefix = "summary_${safeTitle}_" + val files = cacheDir.listFiles()?.filter { it.name.startsWith(prefix) && it.name.endsWith(".txt") } ?: emptyList() + + return files.mapNotNull { file -> + try { + val indexStr = file.name.removePrefix(prefix).removeSuffix(".txt") + val index = indexStr.toInt() + + val fullText = file.readText() + val lines = fullText.lines() + + val title = lines.firstOrNull()?.trim() ?: "Chapter ${index + 1}" + val summaryText = if (lines.size > 1) lines.drop(1).joinToString("\n") else "" + + Timber.d("Cache Load: Ch $index, Title: $title") + + CachedSummaryItem(index, title, summaryText, file) + } catch (e: Exception) { + Timber.e(e, "Error parsing cache file: ${file.name}") + null + } + }.sortedBy { it.chapterIndex } + } + + fun deleteSummary(bookTitle: String, chapterIndex: Int) { + val file = File(cacheDir, getFileName(bookTitle, chapterIndex)) + if (file.exists()) file.delete() + } + + fun clearBookCache(bookTitle: String) { + getAllSummaries(bookTitle).forEach { it.file.delete() } + } +} + @Stable class SearchState( private val scope: CoroutineScope, @@ -275,7 +367,7 @@ fun rememberSearchState( } } -private val activeTooltipState = mutableStateOf(null) +private val activeTooltipState = mutableStateOf(null) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -288,7 +380,7 @@ fun TooltipIconButton( content: @Composable () -> Unit ) { val tooltipState = rememberTooltipState(isPersistent = true) - val scope = rememberCoroutineScope() + rememberCoroutineScope() LaunchedEffect(tooltipState.isVisible) { if (tooltipState.isVisible) { @@ -499,175 +591,6 @@ fun SearchNavigationControls( } } -@androidx.annotation.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() - val context = LocalContext.current - 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(stringResource(R.string.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 = loadTtsMode(context), - playbackSource = "POPUP" - ) - } - } - }, - enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP") - ) { - Icon( - imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow, - contentDescription = stringResource(if (isTtsSessionActive) R.string.action_stop else R.string.action_read_aloud) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = { - clipboardManager.setText(AnnotatedString(textToUse)) - }) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = stringResource(R.string.action_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(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(stringResource(R.string.no_summary_generated), style = MaterialTheme.typography.bodyLarge) - } - } - } - } - } -} - @androidx.annotation.OptIn(UnstableApi::class) @Composable fun AiDefinitionPopup( @@ -676,7 +599,8 @@ fun AiDefinitionPopup( isLoading: Boolean, onDismiss: () -> Unit, isMainTtsActive: Boolean = false, - onOpenExternalDictionary: () -> Unit + onOpenExternalDictionary: () -> Unit, + getAuthToken: suspend () -> String? ) { val ttsController = rememberTtsController() val ttsState by ttsController.ttsState.collectAsState() @@ -760,14 +684,18 @@ fun AiDefinitionPopup( TtsChunk(it, "", -1) } if (chunks.isNotEmpty()) { - ttsController.start( - chunks = chunks, - bookTitle = "AI Definition", - chapterTitle = word, - coverImageUri = null, - ttsMode = loadTtsMode(context), - playbackSource = "POPUP" - ) + scope.launch { + val token = getAuthToken() + ttsController.start( + chunks = chunks, + bookTitle = "AI Definition", + chapterTitle = word, + coverImageUri = null, + ttsMode = loadTtsMode(context), + playbackSource = "POPUP", + authToken = token + ) + } } } }, @@ -912,6 +840,7 @@ fun SearchResultsPanel( suspend fun fetchAiDefinition( text: String, context: Context, + authToken: String?, onUpdate: (String) -> Unit, onError: (String) -> Unit, onFinish: () -> Unit @@ -931,6 +860,9 @@ suspend fun fetchAiDefinition( connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") + if (authToken != null) { + connection.setRequestProperty("Authorization", "Bearer $authToken") + } connection.connectTimeout = 10000 connection.readTimeout = 30000 connection.doOutput = true @@ -942,6 +874,11 @@ suspend fun fetchAiDefinition( } val responseCode = connection.responseCode + if (responseCode == 402) { + onError("INSUFFICIENT_CREDITS") + onFinish() + return@withContext + } Timber.d("Definition: Got response code $responseCode") if (responseCode == HttpURLConnection.HTTP_OK) { var hasReceivedData = false @@ -1053,51 +990,13 @@ object MarkdownParser { } } -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, currentText: String, context: Context, + authToken: String?, onUpdate: (String) -> Unit, + onCostReceived: (Double) -> Unit = {}, onError: (String) -> Unit, onFinish: () -> Unit ) { @@ -1115,13 +1014,16 @@ suspend fun fetchRecap( connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") + if (authToken != null) { + connection.setRequestProperty("Authorization", "Bearer $authToken") + } connection.connectTimeout = 15000 connection.readTimeout = 120000 connection.doOutput = true connection.doInput = true val jsonPayload = JSONObject().apply { - put("past_summaries", org.json.JSONArray(pastSummaries)) + put("past_summaries", JSONArray(pastSummaries)) put("current_text", currentText) } @@ -1130,6 +1032,11 @@ suspend fun fetchRecap( } val responseCode = connection.responseCode + if (responseCode == 402) { + onError("INSUFFICIENT_CREDITS") + onFinish() + return@withContext + } if (responseCode == HttpURLConnection.HTTP_OK) { var hasReceivedData = false connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> @@ -1137,6 +1044,9 @@ suspend fun fetchRecap( while (reader.readLine().also { line = it } != null) { try { val jsonResponse = JSONObject(line!!) + + jsonResponse.optDouble("cost_deducted", -1.0).takeIf { it > -1.0 }?.let { onCostReceived(it) } + jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { onUpdate(it) hasReceivedData = true @@ -1165,6 +1075,7 @@ suspend fun fetchRecap( } @androidx.annotation.OptIn(UnstableApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TtsSettingsSheet( isVisible: Boolean, @@ -1173,178 +1084,404 @@ fun TtsSettingsSheet( onModeChange: (TtsPlaybackManager.TtsMode) -> Unit, currentSpeakerId: String, onSpeakerChange: (String) -> Unit, - isTtsActive: Boolean + isTtsActive: Boolean, + getAuthToken: suspend () -> String?, + bookTitle: String ) { - if (isVisible) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - rememberLazyListState() - val context = LocalContext.current - val scope = rememberCoroutineScope() + if (!isVisible) return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val samplePlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) } + val isOss = BuildConfig.FLAVOR == "oss" + var selectedTabIndex by remember(currentMode) { mutableIntStateOf(if (currentMode == TtsPlaybackManager.TtsMode.CLOUD && !isOss) 0 else 1) } - DisposableEffect(Unit) { - onDispose { samplePlayer.release() } - } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val samplePlayer = remember(context, scope) { + SpeakerSamplePlayer(context, scope, getAuthToken = getAuthToken) + } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentWindowInsets = { WindowInsets.navigationBars } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp) - ) { - Text( - text = stringResource(R.string.tts_settings), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) + DisposableEffect(Unit) { onDispose { samplePlayer.release() } } - if (isTtsActive) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(12.dp) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 24.dp)) { + Text(stringResource(R.string.tts_settings), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp)) + + if (isTtsActive) { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { + Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer) + Spacer(Modifier.width(12.dp)) + Text(stringResource(R.string.tts_stop_to_change_settings), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) + } + } + } + + if (isOss) { + Spacer(Modifier.height(16.dp)) + DeviceVoicesTab(isTtsActive, context, TtsPlaybackManager.TtsMode.BASE) + } else { + Text("Active TTS Engine", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth().height(48.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(24.dp)).padding(4.dp)) { + val modes = listOf(TtsPlaybackManager.TtsMode.CLOUD to "Cloud AI", TtsPlaybackManager.TtsMode.BASE to "Device Native") + modes.forEach { (mode, title) -> + val isSelected = currentMode == mode + Box( + modifier = Modifier.weight(1f).fillMaxHeight().clip(RoundedCornerShape(20.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable(enabled = !isTtsActive) { + onModeChange(mode) + if (mode == TtsPlaybackManager.TtsMode.CLOUD && selectedTabIndex == 1) selectedTabIndex = 0 + if (mode == TtsPlaybackManager.TtsMode.BASE && selectedTabIndex != 1) selectedTabIndex = 1 + }, + contentAlignment = Alignment.Center ) { - Icon(Icons.Default.Stop, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer) - Spacer(Modifier.width(12.dp)) - Text( - stringResource(R.string.tts_stop_to_change_settings), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) + Text(title, color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) } } } - Text( - text = stringResource(R.string.tts_synthesis_mode), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) + Spacer(Modifier.height(16.dp)) - // Mode Selector - Row( - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(25.dp)) - .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - TtsPlaybackManager.TtsMode.entries.forEach { mode -> - val isSelected = currentMode == mode - val label = if (mode == TtsPlaybackManager.TtsMode.BASE) stringResource(R.string.tts_mode_on_device) else stringResource(R.string.tts_mode_cloud_hq) - val icon = if (mode == TtsPlaybackManager.TtsMode.BASE) Icons.Default.Smartphone else Icons.Default.Cloud + TabRow(selectedTabIndex = selectedTabIndex, containerColor = Color.Transparent, divider = {}) { + Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text("Cloud Voices", maxLines = 1, overflow = TextOverflow.Ellipsis) }) + Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text("Device Voices", maxLines = 1, overflow = TextOverflow.Ellipsis) }) + Tab(selected = selectedTabIndex == 2, onClick = { selectedTabIndex = 2 }, text = { Text("Cloud Cache", maxLines = 1, overflow = TextOverflow.Ellipsis) }) + } - Surface( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable(enabled = !isTtsActive) { onModeChange(mode) }, - shape = RoundedCornerShape(25.dp), - color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, - contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + Spacer(Modifier.height(16.dp)) + + when (selectedTabIndex) { + 0 -> AiVoicesTab(currentSpeakerId, onSpeakerChange, isTtsActive, samplePlayer, currentMode) + 1 -> DeviceVoicesTab(isTtsActive, context, currentMode) + 2 -> TtsCacheTab(bookTitle, context, currentSpeakerId) + } + } + } + } +} + +@UnstableApi +@Composable +fun AiVoicesTab( + currentSpeakerId: String, + onSpeakerChange: (String) -> Unit, + isTtsActive: Boolean, + samplePlayer: SpeakerSamplePlayer, + currentMode: TtsPlaybackManager.TtsMode +) { + LocalContext.current + val isCloudMode = currentMode == TtsPlaybackManager.TtsMode.CLOUD + + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Select High-Quality Cloud Voice", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + if (samplePlayer.cachedSpeakers.isNotEmpty()) { + TextButton(onClick = { samplePlayer.clearSamples() }, modifier = Modifier.heightIn(min = 24.dp)) { + Text("Clear Samples", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelMedium) + } + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp).border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))) { + items(GEMINI_TTS_SPEAKERS.size) { index -> + val voice = GEMINI_TTS_SPEAKERS[index] + val isSelected = currentSpeakerId == voice.id + val isCached = samplePlayer.cachedSpeakers.contains(voice.id) + + ListItem( + headlineContent = { Text(voice.name, fontWeight = if (isSelected && isCloudMode) FontWeight.Bold else FontWeight.Normal) }, + supportingContent = { Text(voice.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }, + leadingContent = { + if (isSelected && isCloudMode) { + Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.primary) + } else { + Icon(Icons.Default.Cloud, null, tint = Color.Gray) + } + }, + trailingContent = { + if (!isTtsActive) { + IconButton(onClick = { samplePlayer.playOrStop(voice.id) }) { + if (samplePlayer.loadingSpeakerId == voice.id) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon( + if (samplePlayer.playingSpeakerId == voice.id) Icons.Default.Stop + else if (isCached) Icons.Default.PlayCircle + else Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } } } - } + }, + modifier = Modifier.clickable(enabled = !isTtsActive && isCloudMode) { onSpeakerChange(voice.id) }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected && isCloudMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent + ) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + } + } +} - Spacer(Modifier.height(24.dp)) +@androidx.annotation.OptIn(UnstableApi::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceVoicesTab( + isTtsActive: Boolean, + context: Context, + currentMode: TtsPlaybackManager.TtsMode +) { + var savedVoiceName by remember { mutableStateOf(loadNativeVoice(context)) } + var ttsEngine by remember { mutableStateOf(null) } + var allVoices by remember { mutableStateOf>(emptyList()) } + var isTtsLoading by remember { mutableStateOf(true) } - if (currentMode == TtsPlaybackManager.TtsMode.CLOUD) { - Text( - text = stringResource(R.string.tts_voice_selection), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) - ) + var selectedLanguage by remember { mutableStateOf("All") } + var languageMenuExpanded by remember { mutableStateOf(false) } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)) - ) { - items(GOOGLE_TTS_SPEAKERS) { (name, id) -> - val isSelected = currentSpeakerId == id - val isPlaying = samplePlayer.playingSpeakerId == id - val isLoading = samplePlayer.loadingSpeakerId == id + DisposableEffect(Unit) { + val tts = TextToSpeech(context) { status -> + if (status == TextToSpeech.SUCCESS) { + allVoices = ttsEngine?.voices?.toList()?.sortedBy { it.locale.displayName } ?: emptyList() + isTtsLoading = false + } + } + ttsEngine = tts + onDispose { tts.shutdown() } + } - ListItem( - headlineContent = { Text(name, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, - leadingContent = { - if (isSelected) { - Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary) - } else { - Icon(Icons.Default.GraphicEq, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) - } - }, - trailingContent = { - if (!isTtsActive) { - IconButton(onClick = { samplePlayer.playOrStop(id) }) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - Icon( - imageVector = if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.tts_play_sample), - tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - }, - colors = ListItemDefaults.colors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) else Color.Transparent - ), - modifier = Modifier.clickable(enabled = !isTtsActive) { - onSpeakerChange(id) - } - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - } + if (isTtsLoading) { + Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return + } + + val languages = remember(allVoices) { + val list = listOf("All") + allVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted() + Timber.tag("TTS_DIAGNOSE").d("Languages list updated: size=${list.size}, items=$list") + list + } + + val filteredVoices = remember(allVoices, selectedLanguage) { + if (selectedLanguage == "All") allVoices + else allVoices.filter { it.locale.displayLanguage == selectedLanguage } + } + + val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE + + Surface( + color = if (isBaseMode && savedVoiceName == null) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .clickable(enabled = !isTtsActive && isBaseMode) { + savedVoiceName = null + saveNativeVoice(context, null) + } + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Smartphone, + null, + tint = if (isBaseMode && savedVoiceName == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("System Default Voice", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Uses device settings", style = MaterialTheme.typography.bodySmall) + } + if (isBaseMode && savedVoiceName == null) Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.primary) + } + } + + androidx.compose.material3.ExposedDropdownMenuBox( + expanded = languageMenuExpanded, + onExpandedChange = { if (!isTtsActive) languageMenuExpanded = it }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) { + OutlinedTextField( + value = selectedLanguage, + onValueChange = {}, + readOnly = true, + label = { Text("Language Filter") }, + trailingIcon = { androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon(expanded = languageMenuExpanded) }, + colors = androidx.compose.material3.ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier.fillMaxWidth().menuAnchor(), + enabled = !isTtsActive + ) + ExposedDropdownMenu( + expanded = languageMenuExpanded, + onDismissRequest = { languageMenuExpanded = false } + ) { + languages.forEach { lang -> + Timber.tag("TTS_DIAGNOSE").d("Rendering Language DropdownMenuItem: '$lang'") + DropdownMenuItem( + text = { + Text(text = lang) + }, + onClick = { + selectedLanguage = lang + languageMenuExpanded = false + Timber.tag("TTS_DIAGNOSE").d("Language selected: $lang") } - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp), - contentAlignment = Alignment.Center + ) + } + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp).border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))) { + items(filteredVoices.size) { index -> + val voice = filteredVoices[index] + val isSelected = isBaseMode && voice.name == savedVoiceName + + ListItem( + headlineContent = { Text(voice.locale.displayName, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + supportingContent = { Text(if (voice.isNetworkConnectionRequired) "Online" else "Offline") }, + leadingContent = { + if (isSelected) { + Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.primary) + } else { + Spacer(Modifier.size(24.dp)) + } + }, + modifier = Modifier.clickable(enabled = !isTtsActive && isBaseMode) { + savedVoiceName = voice.name + saveNativeVoice(context, voice.name) + }, + colors = ListItemDefaults.colors(containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(0.2f) else Color.Transparent), + trailingContent = { + IconButton( + enabled = !isTtsActive, + onClick = { + ttsEngine?.apply { + language = voice.locale + speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}") + } + } ) { - Text( - "Using system default engine settings.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Icon(Icons.Default.PlayArrow, contentDescription = "Play Sample", tint = MaterialTheme.colorScheme.primary) + } + } + ) + HorizontalDivider() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TtsCacheTab(bookTitle: String, context: Context, currentSpeakerId: String) { + val cacheManager = remember { TtsCacheManager(context) } + var selectedSpeakerFilter by remember { mutableStateOf(currentSpeakerId) } + var filterMenuExpanded by remember { mutableStateOf(false) } + + val allSpeakers = remember(bookTitle) { + val fromCache = cacheManager.getBookCacheDir(bookTitle).listFiles()?.flatMap { ch -> + ch.listFiles()?.mapNotNull { file -> + val parts = file.name.split("_") + if (parts.size >= 5) parts[3] else null + } ?: emptyList() + }?.distinct()?.sorted() ?: emptyList() + val list = (listOf(currentSpeakerId) + fromCache).distinct() + Timber.tag("TTS_DIAGNOSE").d("AllSpeakers list updated: size=${list.size}, items=$list") + list + } + + var chapters by remember(selectedSpeakerFilter) { mutableStateOf(cacheManager.getChapterCaches(bookTitle, selectedSpeakerFilter)) } + val totalSize = remember(chapters) { chapters.sumOf { it.sizeBytes } } + + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Cloud TTS Cache", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(8.dp)) { + Text(formatBytes(totalSize), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) + } + } + + if (allSpeakers.isNotEmpty()) { + androidx.compose.material3.ExposedDropdownMenuBox( + expanded = filterMenuExpanded, + onExpandedChange = { filterMenuExpanded = it }, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp) + ) { + OutlinedTextField( + value = selectedSpeakerFilter, + onValueChange = {}, + readOnly = true, + label = { Text("Voice Filter") }, + trailingIcon = { androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterMenuExpanded) }, + colors = androidx.compose.material3.ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = filterMenuExpanded, + onDismissRequest = { filterMenuExpanded = false } + ) { + allSpeakers.forEach { spkr -> + Timber.tag("TTS_DIAGNOSE").d("Rendering Voice DropdownMenuItem: '$spkr'") + DropdownMenuItem( + text = { + Text(text = spkr) + }, + onClick = { + selectedSpeakerFilter = spkr + filterMenuExpanded = false + Timber.tag("TTS_DIAGNOSE").d("Voice filter selected: $spkr") + } ) } } } } + + if (chapters.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { + Text("No audio cached for this voice.", style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 240.dp).border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp))) { + items(chapters.size) { index -> + val chapter = chapters[index] + ListItem( + headlineContent = { + Text("${chapter.chapterTitle} (${chapter.chunkCount} chunks)", fontWeight = FontWeight.Medium) + }, + supportingContent = { Text(formatBytes(chapter.sizeBytes)) }, + trailingContent = { + IconButton(onClick = { + cacheManager.deleteSpecificFiles(chapter.matchingFiles, chapter.directory) + chapters = cacheManager.getChapterCaches(bookTitle, selectedSpeakerFilter) + }) { + Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) + } + } + ) + HorizontalDivider() + } + } + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { + chapters.forEach { cacheManager.deleteSpecificFiles(it.matchingFiles, it.directory) } + chapters = cacheManager.getChapterCaches(bookTitle, selectedSpeakerFilter) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer) + ) { + Icon(Icons.Default.Delete, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Clear Cache for $selectedSpeakerFilter") + } + } } } @@ -1362,398 +1499,6 @@ private fun saveNativeVoice(context: Context, @Suppress("SameParameterValue") vo } } -@Composable -fun DeviceVoiceSettingsSheet( - isVisible: Boolean, - onDismiss: () -> Unit -) { - if (isVisible) { - val listState = rememberLazyListState() - val context = LocalContext.current - @Suppress("UnusedVariable", "Unused") val scope = rememberCoroutineScope() - - var ttsEngine by remember { mutableStateOf(null) } - var allVoices by remember { mutableStateOf>(emptyList()) } - var isTtsLoading by remember { mutableStateOf(true) } - - var savedVoiceName by remember { mutableStateOf(loadNativeVoice(context)) } - - val allLanguagesOption = "All Languages" - var selectedLanguage by remember { mutableStateOf(allLanguagesOption) } - - val numberedVoiceNames = remember(allVoices) { - val nameMap = mutableMapOf() - - val groupedByLanguage = allVoices.groupBy { it.locale.displayName } - - groupedByLanguage.forEach { (langName, voiceList) -> - if (voiceList.size > 1) { - voiceList.forEachIndexed { index, voice -> - val type = try { - if (voice.isNetworkConnectionRequired) "Online" else "Offline" - } catch (_: Exception) { - "Offline" - } - - nameMap[voice.name] = "$langName ($type) - ${index + 1}" - } - } else { - val voice = voiceList[0] - val type = try { - if (voice.isNetworkConnectionRequired) "Online" else "Offline" - } catch (_: Exception) { - "Offline" - } - - nameMap[voice.name] = "$langName ($type)" - } - } - nameMap - } - - DisposableEffect(Unit) { - var tts: TextToSpeech? = null - tts = TextToSpeech(context) { status -> - if (status == TextToSpeech.SUCCESS) { - try { - val enginesVoices = tts?.voices - if (enginesVoices != null) { - allVoices = enginesVoices.toList().sortedBy { it.locale.displayName } - } - } catch (e: Exception) { - Timber.e(e, "Failed to fetch voices") - } finally { - isTtsLoading = false - } - } else { - isTtsLoading = false - Timber.e("TTS Initialization failed with status $status") - } - } - ttsEngine = tts - onDispose { - tts.shutdown() - } - } - - val availableLanguages = remember(allVoices) { - val languages = - allVoices.asSequence().map { it.locale.displayLanguage }.filter { it.isNotBlank() } - .distinct().sorted().toMutableList() - - languages.add(0, allLanguagesOption) - languages - } - - LaunchedEffect(allVoices, savedVoiceName) { - if (savedVoiceName != null && allVoices.isNotEmpty()) { - val savedVoice = allVoices.find { it.name == savedVoiceName } - if (savedVoice != null) { - val voiceLanguage = savedVoice.locale.displayLanguage - if (selectedLanguage == allLanguagesOption) { - selectedLanguage = voiceLanguage - } - } - } - } - - val filteredVoices = remember(selectedLanguage, allVoices) { - if (selectedLanguage == allLanguagesOption) { - allVoices - } else { - allVoices.filter { it.locale.displayLanguage == selectedLanguage } - } - } - - LaunchedEffect(filteredVoices, savedVoiceName) { - if (savedVoiceName != null && filteredVoices.isNotEmpty()) { - val index = filteredVoices.indexOfFirst { it.name == savedVoiceName } - if (index != -1) { - Timber.d("Auto-scrolling to voice at index: $index") - delay(300) - listState.animateScrollToItem(index) - } - } - } - - androidx.compose.ui.window.Dialog( - onDismissRequest = onDismiss, properties = androidx.compose.ui.window.DialogProperties( - usePlatformDefaultWidth = false - ) - ) { - Column( - modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom - ) { - Box( - modifier = Modifier.weight(1f).fillMaxWidth().clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, - indication = null, - onClick = onDismiss - ) - ) - - Surface( - modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f), - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp - ) { - Column( - modifier = Modifier.fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(horizontal = 24.dp).padding(bottom = 24.dp, top = 24.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.tts_device_voice_settings), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.content_desc_close_settings)) - } - } - - Surface( - color = if (savedVoiceName == null) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(16.dp), - tonalElevation = if (savedVoiceName == null) 4.dp else 0.dp, - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).clickable { - savedVoiceName = null - saveNativeVoice(context, null) - Timber.d("Native TTS: Reset to System Default") - }) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Smartphone, - contentDescription = null, - tint = if (savedVoiceName == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.tts_system_default), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = stringResource(R.string.tts_system_default_desc), - style = MaterialTheme.typography.bodySmall - ) - } - if (savedVoiceName == null) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - - HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) - - if (isTtsLoading) { - Box( - modifier = Modifier.fillMaxWidth().height(200.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.tts_loading_voices), modifier = Modifier.padding(top = 48.dp)) - } - } else if (allVoices.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().height(100.dp), - contentAlignment = Alignment.Center - ) { - Text( - stringResource(R.string.tts_no_voices), - color = MaterialTheme.colorScheme.error - ) - } - } else { - var expandedLanguageMenu by remember { mutableStateOf(false) } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.tts_specific_voices), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Box(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { - Surface( - modifier = Modifier.fillMaxWidth().height(50.dp) - .clickable { expandedLanguageMenu = true }, - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke( - 1.dp, MaterialTheme.colorScheme.outlineVariant - ) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = selectedLanguage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - DropdownMenu( - expanded = expandedLanguageMenu, - onDismissRequest = { expandedLanguageMenu = false }, - modifier = Modifier.fillMaxWidth(0.85f).heightIn(max = 400.dp) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - availableLanguages.forEach { language -> - DropdownMenuItem(text = { - Text( - text = language, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - }, onClick = { - selectedLanguage = language - expandedLanguageMenu = false - }) - } - } - } - - if (filteredVoices.isNotEmpty()) { - Text( - text = stringResource(R.string.tts_available_voices_count, filteredVoices.size), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp, start = 4.dp) - ) - - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth().weight(1f, fill = false) - .padding(vertical = 4.dp).border( - 1.dp, - MaterialTheme.colorScheme.outlineVariant, - RoundedCornerShape(12.dp) - ) - ) { - items( - filteredVoices.size, - key = { "${filteredVoices[it].name}_$it" }) { index -> - val voice = filteredVoices[index] - val isSelected = voice.name == savedVoiceName - val friendlyName = numberedVoiceNames[voice.name] - ?: voice.locale.displayName - - ListItem( - headlineContent = { - Text( - text = friendlyName, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface - ) - }, - supportingContent = if (voice.locale.variant.isNotEmpty()) { - { Text(stringResource(R.string.tts_voice_variant, voice.locale.variant)) } - } else null, - leadingContent = { - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = stringResource(R.string.content_desc_selected), - tint = MaterialTheme.colorScheme.primary - ) - } else { - Spacer(modifier = Modifier.size(24.dp)) - } - }, - trailingContent = { - IconButton(onClick = { - val params = android.os.Bundle() - try { - ttsEngine?.language = voice.locale - } catch (e: Exception) { - Timber.e(e, "Failed to set language for sample") - } - ttsEngine?.voice = voice - val sampleText = context.getString(R.string.tts_voice_sample_text, voice.locale.displayLanguage) - ttsEngine?.speak( - sampleText, - TextToSpeech.QUEUE_FLUSH, - params, - "SAMPLE_ID" - ) - }) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.tts_play_sample), - tint = MaterialTheme.colorScheme.primary - ) - } - }, - modifier = Modifier.clickable { - savedVoiceName = voice.name - saveNativeVoice(context, voice.name) - }.background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy( - alpha = 0.2f - ) else Color.Transparent - ) - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ) - ) - } - } - } else { - Box( - modifier = Modifier.fillMaxWidth().padding(24.dp), - contentAlignment = Alignment.Center - ) { - Text( - stringResource(R.string.tts_no_voices_for_language), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } - } - } -} - @Composable fun SpectrumBox( hue: Float, @@ -2023,12 +1768,12 @@ fun ColorComparePill( Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) { drawRect( color = oldColor.copy(alpha = 1f), - size = androidx.compose.ui.geometry.Size(size.width / 2, size.height) + size = Size(size.width / 2, size.height) ) drawRect( color = newColor.copy(alpha = 1f), topLeft = Offset(size.width / 2, 0f), - size = androidx.compose.ui.geometry.Size(size.width / 2, size.height) + size = Size(size.width / 2, size.height) ) } } @@ -2091,7 +1836,7 @@ fun loadCustomThemes(context: Context): List { val jsonString = prefs.getString(PREF_CUSTOM_THEMES, "[]") ?: "[]" val themes = mutableListOf() try { - val jsonArray = org.json.JSONArray(jsonString) + val jsonArray = JSONArray(jsonString) for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) themes.add( @@ -2221,8 +1966,8 @@ fun ThemeGrid( onEdit: ((ReaderTheme) -> Unit)? = null, onDelete: ((ReaderTheme) -> Unit)? = null ) { - androidx.compose.foundation.lazy.grid.LazyVerticalGrid( - columns = androidx.compose.foundation.lazy.grid.GridCells.Adaptive(minSize = 80.dp), + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 80.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -2303,7 +2048,7 @@ fun ThemeBuilderView( Spacer(Modifier.height(16.dp)) Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { - androidx.compose.material3.OutlinedTextField( + OutlinedTextField( value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.theme_name)) }, @@ -2461,9 +2206,9 @@ fun ThemeColorPickerDialog( value = hsv[2] } - androidx.compose.ui.window.Dialog( + Dialog( onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + properties = DialogProperties(usePlatformDefaultWidth = false) ) { Surface( shape = RoundedCornerShape(24.dp), @@ -2585,7 +2330,7 @@ fun ThemeColorPickerDialog( ) { Button( onClick = onDismiss, - colors = androidx.compose.material3.ButtonDefaults.buttonColors( + colors = ButtonDefaults.buttonColors( containerColor = Color.White ) ) { @@ -2597,28 +2342,6 @@ fun ThemeColorPickerDialog( } } -@Composable -fun TextureOption(name: String, resId: Int?, isSelected: Boolean, onClick: () -> Unit) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) { - Box(modifier = Modifier.size(48.dp).clip(CircleShape).border(if (isSelected) 3.dp else 1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape).run { - if (resId != null) { - val bmp = ImageBitmap.imageResource(LocalResources.current, resId) - this.drawBehind { drawRect(ShaderBrush(ImageShader(bmp, TileMode.Repeated, TileMode.Repeated))) } - } else this.background(MaterialTheme.colorScheme.surfaceVariant) - }) - Text(name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp)) - } -} - -@Composable -fun ColorSlider(color: Color, onColorChanged: (Color) -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Slider(value = color.red, onValueChange = { onColorChanged(color.copy(red = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Red, activeTrackColor = Color.Red), modifier = Modifier.weight(1f)) - Slider(value = color.green, onValueChange = { onColorChanged(color.copy(green = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Green, activeTrackColor = Color.Green), modifier = Modifier.weight(1f)) - Slider(value = color.blue, onValueChange = { onColorChanged(color.copy(blue = it)) }, colors = androidx.compose.material3.SliderDefaults.colors(thumbColor = Color.Blue, activeTrackColor = Color.Blue), modifier = Modifier.weight(1f)) - } -} - @Composable fun HighlightColorPickerDialog( initialColors: Map, @@ -2668,9 +2391,9 @@ fun HighlightColorPickerDialog( value = hsv[2] } - androidx.compose.ui.window.Dialog( + Dialog( onDismissRequest = onDismiss, - properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + properties = DialogProperties(usePlatformDefaultWidth = false) ) { Surface( shape = RoundedCornerShape(24.dp), @@ -2809,7 +2532,7 @@ fun HighlightColorPickerDialog( Spacer(Modifier.width(8.dp)) Button( onClick = { onSave(currentColors) }, - colors = androidx.compose.material3.ButtonDefaults.buttonColors( + colors = ButtonDefaults.buttonColors( containerColor = Color.White ) ) { @@ -2820,4 +2543,433 @@ fun HighlightColorPickerDialog( } } } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AiHubBottomSheet( + bookTitle: String, + currentChapterIndex: Int, + chapterTitle: String, + summaryCacheManager: SummaryCacheManager? = null, + summarizationResult: SummarizationResult?, + isSummarizationLoading: Boolean, + onGenerateSummary: (Boolean) -> Unit, + onClearSummary: () -> Unit = {}, + recapResult: SummarizationResult? = null, + isRecapLoading: Boolean = false, + onGenerateRecap: (() -> Unit)? = null, + onClearRecap: () -> Unit = {}, + onDismiss: () -> Unit, + isMainTtsActive: Boolean, + getAuthToken: suspend () -> String?, + credits: Int, + isProUser: Boolean +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedTabIndex by remember { mutableIntStateOf(0) } + val ttsController = rememberTtsController() + val ttsState by ttsController.ttsState.collectAsState() + + LaunchedEffect(currentChapterIndex) { + onClearSummary() + } + + DisposableEffect(Unit) { + onDispose { + if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) { + ttsController.stop() + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentWindowInsets = { WindowInsets.navigationBars } + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).heightIn(min = 300.dp, max = 600.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) + Text( + text = "AI Features", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.weight(2f) + ) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + if (BuildConfig.FLAVOR != "oss") { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "⭐ $credits", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + } + + val tabs = mutableListOf("Summary") + if (onGenerateRecap != null) tabs.add("Recap") + if (summaryCacheManager != null) tabs.add("Cache") + + TabRow(selectedTabIndex = selectedTabIndex, modifier = Modifier.padding(bottom = 16.dp)) { + tabs.forEachIndexed { index, title -> + Tab(selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }) { + Text(title, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.titleSmall) + } + } + } + + val activeTab = tabs.getOrNull(selectedTabIndex) ?: "Summary" + var cacheRefreshTrigger by remember { mutableIntStateOf(0) } + + when (activeTab) { + "Summary" -> { + val cachedSummary = remember(currentChapterIndex, cacheRefreshTrigger) { summaryCacheManager?.getSummary(bookTitle, currentChapterIndex) } + val effectiveResult = summarizationResult ?: if (cachedSummary != null) SummarizationResult(summary = cachedSummary, isCacheHit = true) else null + + if (effectiveResult == null && !isSummarizationLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painterResource(R.drawable.summarize), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(16.dp)) + Text("No summary for ${chapterTitle.lowercase()} yet.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onGenerateSummary(false) }, + modifier = Modifier.fillMaxWidth(0.8f).padding(vertical = 8.dp) + ) { + Icon(painterResource(R.drawable.ai), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Generate Summary for $chapterTitle") + } + } + } + } else { + AiResultContentView( + title = chapterTitle, + result = effectiveResult, + isLoading = isSummarizationLoading, + isMainTtsActive = isMainTtsActive, + ttsController = ttsController, + ttsState = ttsState, + getAuthToken = getAuthToken, + onRegenerate = { onGenerateSummary(true) } + ) + } + } + "Recap" -> { + // Recap Tab + if (recapResult == null && !isRecapLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(painterResource(R.drawable.ai), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(16.dp)) + Text("Get a recap of the story up to your current position.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { onGenerateRecap?.invoke() }, + modifier = Modifier.fillMaxWidth(0.8f).padding(vertical = 8.dp) + ) { + Icon(painterResource(R.drawable.ai), contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Generate Story Recap") + } + } + } + } else { + AiResultContentView( + title = "Story Recap", + result = recapResult, + isLoading = isRecapLoading, + isMainTtsActive = isMainTtsActive, + ttsController = ttsController, + ttsState = ttsState, + getAuthToken = getAuthToken, + onRegenerate = { onGenerateRecap?.invoke() }, + onClear = onClearRecap + ) + } + } + "Cache" -> { + if (summaryCacheManager != null) { + ManageCacheTab(bookTitle, summaryCacheManager, onCacheChanged = { + cacheRefreshTrigger++ + onClearSummary() + }) + } + } + } + } + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun AiResultContentView( + title: String, + result: SummarizationResult?, + isLoading: Boolean, + isMainTtsActive: Boolean, + ttsController: com.aryan.reader.tts.TtsController, + ttsState: TtsPlaybackManager.TtsState, + getAuthToken: suspend () -> String?, + onRegenerate: (() -> Unit)? = null, + onClear: (() -> Unit)? = null +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 8.dp) + ) + + if (result != null && (!result.summary.isNullOrBlank() || isLoading)) { + Surface( + color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( + 0xFF4CAF50 + ).copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = if (result.isCacheHit) { + "⚡ Cache Hit • Free" + } else if (result.cost != null) { + if (result.cost == 0.0 && result.freeRemaining != null) { + "✨ Generated • Free (${result.freeRemaining}/10 left)" + } else { + "✨ Generated • Cost: ${result.cost} credits" + } + } else { + "✨ Generating... • Cost: Calculating" + }, + style = MaterialTheme.typography.labelSmall, + color = if (result.isCacheHit || (result.cost == 0.0 && result.freeRemaining != null)) Color( + 0xFF388E3C + ) else MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + + if (isLoading && (result?.summary.isNullOrBlank() && result?.error.isNullOrBlank())) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator() + Text("Thinking...", 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 + + if (onClear != null && !isLoading) { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.error + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + if (onRegenerate != null) { + TextButton(onClick = onRegenerate) { + Text("Regenerate") + } + } + + IconButton( + onClick = { + if (isTtsSessionActive) { + ttsController.stop() + } else { + val chunks = splitTextIntoChunks(textToUse).map { TtsChunk(it, "", -1) } + if (chunks.isNotEmpty()) { + scope.launch { + val token = getAuthToken() + ttsController.start( + chunks = chunks, + bookTitle = title, + chapterTitle = "AI Output", + coverImageUri = null, + ttsMode = loadTtsMode(context), + playbackSource = "POPUP", + authToken = token + ) + } + } + } + }, + enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP") + ) { + Icon( + imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = "Read Aloud" + ) + } + 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(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).weight(1f, fill = false), + onTextLayout = { textLayoutResult = it } + ) + } + } + } +} + +@Composable +fun ManageCacheTab(bookTitle: String, summaryCacheManager: SummaryCacheManager, onCacheChanged: () -> Unit = {}) { + var cachedItems by androidx.compose.runtime.remember { mutableStateOf(summaryCacheManager.getAllSummaries(bookTitle)) } + + if (cachedItems.isEmpty()) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("No cached summaries for this book.", style = MaterialTheme.typography.bodyMedium) + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(1f).padding(vertical = 8.dp)) { + items(cachedItems.size) { index -> + val item = cachedItems[index] + var expanded by androidx.compose.runtime.remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }) { + ListItem( + headlineContent = { + Text( + text = item.chapterTitle, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + }, + trailingContent = { + IconButton(onClick = { + summaryCacheManager.deleteSummary(bookTitle, item.chapterIndex) + cachedItems = summaryCacheManager.getAllSummaries(bookTitle) + onCacheChanged() + }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + ) + AnimatedVisibility(visible = expanded) { + Text( + text = item.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + HorizontalDivider() + } + } + } + TextButton( + onClick = { + summaryCacheManager.clearBookCache(bookTitle) + cachedItems = emptyList() + onCacheChanged() + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Clear All", color = MaterialTheme.colorScheme.error) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 103ed74..77b7433 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -1039,6 +1039,19 @@ private fun AppDrawerContent( uiState.currentUser.email?.let { email -> Text(text = email, style = MaterialTheme.typography.bodyMedium) } + if (BuildConfig.FLAVOR == "pro") { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = CircleShape, + modifier = Modifier.padding(top = 8.dp) + ) { + Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.FormatListNumbered, contentDescription = "Credits", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer) + Spacer(modifier = Modifier.width(4.dp)) + Text("${uiState.credits} Credits", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) + } + } + } } } else { // Signed-out: Show Sign In button at the top diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index 1e9b50b..0ca23dc 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -113,6 +113,7 @@ import java.util.UUID import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit import androidx.core.graphics.createBitmap +import kotlinx.coroutines.flow.distinctUntilChanged private const val KEY_RENDER_MODE = "render_mode" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" @@ -214,6 +215,7 @@ data class ReaderScreenState( val currentUser: UserData? = null, val isAuthMenuExpanded: Boolean = false, val isProUser: Boolean = false, + val credits: Int = 0, val isSyncEnabled: Boolean = false, val isFolderSyncEnabled: Boolean = false, val bannerMessage: BannerMessage? = null, @@ -269,7 +271,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private val cloudflareRepository = CloudflareRepository() private val remoteConfigRepository = RemoteConfigRepository() private var userProfileListener: Any? = null - private val migrationAttempted = MutableStateFlow(false) private val _prefsUpdateFlow = MutableStateFlow(0L) private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener private val feedbackRepository = FeedbackRepository(appContext) @@ -800,9 +801,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(hasUnreadFeedback = hasUnread) } } - userProfileListener = - firestoreRepository.listenToUserProfile(newUserData.uid) { isProFromBackend -> - _internalState.update { it.copy(isProUser = isProFromBackend) } + userProfileListener = firestoreRepository.listenToUserProfile(newUserData.uid) { isProFromBackend, creditsFromBackend -> + _internalState.update { it.copy(isProUser = isProFromBackend, credits = creditsFromBackend) } if (isProFromBackend) { verifyDeviceForProUser() @@ -823,13 +823,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } } - triggerLegacyPurchaseMigration() } } else { - _internalState.update { it.copy(isProUser = false, hasUnreadFeedback = false) } + _internalState.update { it.copy(isProUser = false, credits = 0, isSyncEnabled = false, hasUnreadFeedback = false) } } } } + viewModelScope.launch { + combine( + billingClientWrapper.proUpgradeState.map { it.activePurchases }, + _internalState.map { it.currentUser?.uid } + ) { purchases, uid -> + Pair(purchases, uid) + } + .distinctUntilChanged() + .collect { (purchases, uid) -> + if (uid != null && purchases.isNotEmpty()) { + Timber.d("Active purchases or User changed, triggering migration check") + triggerLegacyPurchaseMigration() + } + } + } } private fun getDisplayPathFromUri(context: Context, uriString: String): String { @@ -1010,40 +1024,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio purchase: PurchaseEntity, isSilentMigrationCheck: Boolean = false ) { viewModelScope.launch { - if (!purchase.products.contains(BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID)) { + val productId = purchase.products.firstOrNull() + + if (productId == null || (!productId.startsWith("credits_") && productId != BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID)) { Timber.e("Purchase verification failed: Incorrect product ID.") if (!isSilentMigrationCheck) { - _internalState.update { - it.copy( - bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true) - ) - } + _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true)) } } billingClientWrapper.clearVerificationState() return@launch } - val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken) + val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken, productId) if (result.isSuccess) { Timber.i("Backend verification successful. Firestore will update the app.") - _internalState.update { - it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_upgrade_success))) + + if (productId.startsWith("credits_")) { + billingClientWrapper.consumePurchase(purchase.purchaseToken) + if (!isSilentMigrationCheck) { + _internalState.update { it.copy(bannerMessage = BannerMessage("Credits successfully added!")) } + } + } else { + if (!isSilentMigrationCheck) { + _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_upgrade_success))) } + } + verifyDeviceForProUser() } - verifyDeviceForProUser() } else { val exception = result.exceptionOrNull() if (exception?.message?.contains("already claimed") == true) { - Timber.i( - "Migration check: Purchase token is already claimed by another account. Silently ignoring." - ) + Timber.i("Migration/Refresh check: Purchase token is already claimed. Silently ignoring.") + if (productId.startsWith("credits_")) { + billingClientWrapper.consumePurchase(purchase.purchaseToken) + } } else { val errorMessage = appContext.getString(R.string.error_purchase_verification) Timber.e(exception, "Backend verification failed") if (!isSilentMigrationCheck) { - _internalState.update { - it.copy(bannerMessage = BannerMessage(errorMessage, isError = true)) - } + _internalState.update { it.copy(bannerMessage = BannerMessage(errorMessage, isError = true)) } } } } @@ -2004,27 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private fun triggerLegacyPurchaseMigration() { val user = _internalState.value.currentUser - val isProOnBackend = _internalState.value.isProUser val localPurchases = billingClientWrapper.proUpgradeState.value.activePurchases - val checkedUids = prefs.getStringSet(KEY_MIGRATION_CHECKED_UIDS, emptySet()) ?: emptySet() - if (user != null && user.uid in checkedUids) { - Timber.d( - "Migration check for user ${user.uid} already performed on this device. Skipping." - ) - return // Already checked, do nothing. - } + if (user != null && localPurchases.isNotEmpty()) { + Timber.i("Checking for unconsumed purchases or legacy pro statuses...") - if (user != null && !isProOnBackend && localPurchases.isNotEmpty() && !migrationAttempted.value) { - migrationAttempted.value = true - Timber.i( - "MIGRATION: Found legacy user with local purchase. Verifying with backend silently..." - ) - val purchaseToVerify = localPurchases.first() - - verifyPurchaseWithBackend(purchaseToVerify, isSilentMigrationCheck = true) - - prefs.edit { putStringSet(KEY_MIGRATION_CHECKED_UIDS, checkedUids + user.uid) } + localPurchases.forEach { purchase -> + verifyPurchaseWithBackend(purchase, isSilentMigrationCheck = true) + } } } @@ -2136,9 +2142,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun launchPurchaseFlow(activity: android.app.Activity) { - Timber.d("Attempting to launch purchase flow. Pro state is: ${proUpgradeState.value}") - billingClientWrapper.launchPurchaseFlow(activity) + fun launchPurchaseFlow(activity: android.app.Activity, productId: String = BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID) { + Timber.d("Attempting to launch purchase flow for $productId. Pro state is: ${proUpgradeState.value}") + billingClientWrapper.launchPurchaseFlow(activity, productId) } fun clearBillingError() { @@ -4485,6 +4491,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(useStrictFileFilter = enabled) } } + suspend fun getAuthToken(): String? { + return authRepository.getIdToken() + } + companion object { private const val KEY_SORT_ORDER = "sort_order" internal const val KEY_SHELVES = "shelf_names" @@ -4494,7 +4504,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_ADD_BOOKS_SOURCE = "add_books_source" private const val KEY_SYNC_ENABLED = "sync_enabled" private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" - private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids" private const val KEY_INSTALLATION_ID = "installation_id" private const val KEY_APP_OPEN_COUNT = "app_open_count" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" diff --git a/app/src/main/java/com/aryan/reader/ProScreen.kt b/app/src/main/java/com/aryan/reader/ProScreen.kt index dbe18f4..ac7e217 100644 --- a/app/src/main/java/com/aryan/reader/ProScreen.kt +++ b/app/src/main/java/com/aryan/reader/ProScreen.kt @@ -61,10 +61,12 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.aryan.reader.data.ProductDetailsEntity import kotlinx.coroutines.launch import java.text.NumberFormat import java.util.Currency +@Suppress("KotlinConstantConditions") @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ProScreen( @@ -75,11 +77,12 @@ fun ProScreen( val proUpgradeState by viewModel.proUpgradeState.collectAsState() val uiState by viewModel.uiState.collectAsState() var showExistingPurchaseDialog by remember { mutableStateOf(false) } - var showEarlyAccessInfoDialog by remember { mutableStateOf(false) } var showSignInRequiredDialog by remember { mutableStateOf(false) } - val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 }) - var selectedTabIndex by remember { mutableIntStateOf(1) } + // Removed Free Tab, so tabCount is max 2 + val tabCount = if (BuildConfig.FLAVOR == "pro") 2 else 1 + val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabCount }) + var selectedTabIndex by remember { mutableIntStateOf(0) } val scope = rememberCoroutineScope() LaunchedEffect(pagerState.currentPage) { @@ -92,8 +95,9 @@ fun ProScreen( } } + // Default to the Credits tab if they already own Pro LaunchedEffect(uiState.isProUser) { - if (uiState.isProUser) { + if (uiState.isProUser && BuildConfig.FLAVOR == "pro") { selectedTabIndex = 1 } } @@ -109,10 +113,6 @@ fun ProScreen( ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false }) } - if (showEarlyAccessInfoDialog) { - EarlyAccessInfoDialog(onDismiss = { showEarlyAccessInfoDialog = false }) - } - if (showSignInRequiredDialog) { SignInRequiredDialog( onSignInClick = { @@ -130,7 +130,7 @@ fun ProScreen( Scaffold( topBar = { TopAppBar( - title = { }, // Removed header content + title = { }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -170,48 +170,23 @@ fun ProScreen( .background( if (selectedTabIndex == 0) MaterialTheme.colorScheme.surface else Color.Transparent ) - .border( // Border for selected Free tab + .border( width = if (selectedTabIndex == 0) 2.dp else 0.dp, color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent, shape = CircleShape ), - text = { - AutoSizeText(stringResource(R.string.tab_free), - style = LocalTextStyle.current.copy( - color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold - ) - ) - }, - selectedContentColor = MaterialTheme.colorScheme.primary, - unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - Tab( - selected = selectedTabIndex == 1, - onClick = { selectedTabIndex = 1 }, - modifier = Modifier - .height(56.dp) - .clip(CircleShape) - .background( - if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent - ) - .border( // Border for selected Pro tab - width = if (selectedTabIndex == 1) 2.dp else 0.dp, - color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent, - shape = CircleShape - ), text = { Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(id = R.drawable.crown), contentDescription = "Pro", modifier = Modifier.size(16.dp), - tint = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + tint = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.width(4.dp)) AutoSizeText(stringResource(R.string.drawer_pro_unlocked), style = LocalTextStyle.current.copy( - color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold ) ) @@ -220,6 +195,31 @@ fun ProScreen( selectedContentColor = MaterialTheme.colorScheme.primary, unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant ) + if (BuildConfig.FLAVOR == "pro") { + Tab( + selected = selectedTabIndex == 1, + onClick = { selectedTabIndex = 1 }, + modifier = Modifier + .height(56.dp) + .clip(CircleShape) + .background(if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent) + .border( + width = if (selectedTabIndex == 1) 2.dp else 0.dp, + color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = CircleShape + ), + text = { + AutoSizeText("Credits", + style = LocalTextStyle.current.copy( + color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + ) + }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } Spacer(modifier = Modifier.height(16.dp)) @@ -229,101 +229,40 @@ fun ProScreen( modifier = Modifier.fillMaxWidth().fillMaxHeight(), userScrollEnabled = true ) { page -> - if (page == 0) { - FreeTierCard() - } else { - ProTierCard( - isProUser = uiState.isProUser, - isUserSignedIn = uiState.currentUser != null, - proUpgradeState = proUpgradeState, - onUpgradeClick = { - (context as? Activity)?.let { - viewModel.launchPurchaseFlow(it) - } - }, - onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true }, - onShowEarlyAccessInfo = { showEarlyAccessInfoDialog = true }, - onSignInRequiredClick = { showSignInRequiredDialog = true } - ) + when (page) { + 0 -> { + ProTierCard( + isProUser = uiState.isProUser, + isUserSignedIn = uiState.currentUser != null, + proUpgradeState = proUpgradeState, + onUpgradeClick = { + (context as? Activity)?.let { + viewModel.launchPurchaseFlow(it) + } + }, + onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true }, + onSignInRequiredClick = { showSignInRequiredDialog = true }) + } + + 1 -> { + if (BuildConfig.FLAVOR == "pro") { + CreditTierCard( + credits = uiState.credits, + creditProducts = proUpgradeState.creditProducts, + isVerifying = proUpgradeState.isVerifying, + isUserSignedIn = uiState.currentUser != null, + onSignInRequiredClick = { showSignInRequiredDialog = true }, + onBuyCredits = { productId -> + (context as? Activity)?.let { viewModel.launchPurchaseFlow(it, productId) } + }) + } + } } } } } } -@Composable -private fun FreeTierCard() { - Card( - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.free_plan), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.price_free), - style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp), - fontWeight = FontWeight.Bold - ) - Text(stringResource(R.string.forever_free), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(24.dp)) - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - FeatureListItem(iconRes = R.drawable.library_books, text = stringResource(R.string.feature_multiple_formats)) - Text(stringResource(R.string.feature_multiple_formats_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.text_to_speech, text = stringResource(R.string.feature_tts)) - Text(stringResource(R.string.feature_tts_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - FeatureListItem(iconRes = R.drawable.dictionary, text = stringResource(R.string.feature_dict)) - Text(stringResource(R.string.feature_dict_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 36.dp, bottom = 8.dp) - ) - } - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { /* Do nothing, it's the current plan */ }, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = MaterialTheme.shapes.medium, - enabled = false, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) { - Text(stringResource(R.string.current_plan), fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - } - } -} - @Composable private fun ProTierCard( isProUser: Boolean, @@ -331,7 +270,6 @@ private fun ProTierCard( proUpgradeState: ProUpgradeState, onUpgradeClick: () -> Unit, onShowExistingPurchaseDialog: () -> Unit, - onShowEarlyAccessInfo: () -> Unit, onSignInRequiredClick: () -> Unit ) { val productDetails = proUpgradeState.productDetails @@ -431,27 +369,6 @@ private fun ProTierCard( } } Spacer(modifier = Modifier.height(16.dp)) - - OutlinedButton( - onClick = onShowEarlyAccessInfo, - modifier = Modifier - .height(40.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ), - shape = MaterialTheme.shapes.small, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = "Info", - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.early_access_sale), style = MaterialTheme.typography.labelLarge) - } - Spacer(modifier = Modifier.height(16.dp)) } @@ -678,19 +595,6 @@ fun ExistingPurchaseDialog(onDismiss: () -> Unit) { ) } -@Composable -fun EarlyAccessInfoDialog(onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(Icons.Default.Info, contentDescription = null) }, - title = { Text(stringResource(R.string.early_access_sale)) }, - text = { Text(stringResource(R.string.dialog_early_access_desc)) }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_got_it)) } - } - ) -} - @Composable fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) { AlertDialog( @@ -705,4 +609,145 @@ fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_not_now)) } } ) +} + +@Composable +private fun CreditTierCard( + credits: Int, + creditProducts: List, + isVerifying: Boolean, + isUserSignedIn: Boolean, + onSignInRequiredClick: () -> Unit, + onBuyCredits: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("AI & Cloud Credits", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "$credits", + style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text("Credits Available", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + + Spacer(modifier = Modifier.height(24.dp)) + + if (isVerifying) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + Text(stringResource(R.string.verifying_purchase), style = MaterialTheme.typography.bodySmall) + } else if (creditProducts.isEmpty()) { + Text(stringResource(R.string.loading_price), modifier = Modifier.padding(16.dp)) + } else { + creditProducts.forEach { product -> + OutlinedCard( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + onClick = { + if (isUserSignedIn) onBuyCredits(product.productId) + else onSignInRequiredClick() + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text(product.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + if (product.description.isNotBlank()) { + Text(product.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Button( + onClick = { + if (isUserSignedIn) onBuyCredits(product.productId) + else onSignInRequiredClick() + }, + modifier = Modifier.wrapContentWidth() + ) { + Text(product.formattedPrice) + } + } + } + } + } + + if (!isUserSignedIn) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.sign_in_to_purchase_credits), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + "Estimated Cost Breakdown", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Start) + ) + Spacer(modifier = Modifier.height(16.dp)) + + CostBreakdownItem( + iconRes = R.drawable.text_to_speech, + title = "Cloud TTS", + description = "Cost: ~3-4 credits per minute of audio generated.\nTo enable: Reader Screen > More > TTS Voice Settings." + ) + CostBreakdownItem( + iconRes = R.drawable.summarize, + title = "AI Summaries & Recap", + description = "Cost: ~1-4 credits per request based on chapter length.\nPro Users get 10 free summaries daily." + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun CostBreakdownItem( + @androidx.annotation.DrawableRes iconRes: Int, + title: String, + description: String +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp).padding(top = 2.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(text = title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 1e371b8..a6f9687 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -34,8 +34,8 @@ interface RecentFileDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrUpdateFiles(files: List) - @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") - fun getRecentFiles(): Flow> + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") + fun getRecentFiles(): Flow> @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") suspend fun getFilesBySourceFolder(sourceFolderUri: String): List @@ -46,8 +46,8 @@ interface RecentFileDao { @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) - @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") - fun getRecentFilesList(limit: Int): List + @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") + fun getRecentFilesList(limit: Int): List @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") suspend fun deleteFilePermanently(bookIds: List) diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt index 753cbee..96dd34d 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt @@ -52,4 +52,29 @@ data class RecentFileEntity( @ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?, @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long +) + +data class RecentFileSummary( + val bookId: String, + val uriString: String?, + val type: FileType, + val displayName: String, + val timestamp: Long, + val coverImagePath: String?, + val title: String?, + val author: String?, + @ColumnInfo(name = "lastChapterIndex") val lastChapterIndex: Int?, + val lastPage: Int?, + @ColumnInfo(name = "lastPositionCfi") val lastPositionCfi: String?, + @ColumnInfo(name = "progressPercentage") val progressPercentage: Float?, + @ColumnInfo(defaultValue = "1") val isRecent: Boolean, + @ColumnInfo(defaultValue = "1") val isAvailable: Boolean, + val lastModifiedTimestamp: Long, + @ColumnInfo(defaultValue = "0") val isDeleted: Boolean, + val locatorBlockIndex: Int?, + val locatorCharOffset: Int?, + @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, + @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, + @ColumnInfo(defaultValue = "NULL") val customName: String?, + @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long ) \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt index 5406bfb..c4ce3aa 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt @@ -157,4 +157,33 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem { customName = this.customName, highlightsJson = this.highlightsJson ) +} + +fun RecentFileSummary.toRecentFileItem(): RecentFileItem { + return RecentFileItem( + bookId = this.bookId, + uriString = this.uriString, + type = this.type, + displayName = this.displayName, + timestamp = this.timestamp, + coverImagePath = this.coverImagePath, + title = this.title, + author = this.author, + lastChapterIndex = this.lastChapterIndex, + locatorBlockIndex = this.locatorBlockIndex, + locatorCharOffset = this.locatorCharOffset, + lastPage = this.lastPage, + lastPositionCfi = this.lastPositionCfi, + progressPercentage = this.progressPercentage, + isRecent = this.isRecent, + isAvailable = this.isAvailable, + lastModifiedTimestamp = this.lastModifiedTimestamp, + isDeleted = this.isDeleted, + bookmarksJson = null, + sourceFolderUri = this.sourceFolderUri, + isReflowPreferred = this.isReflowPreferred, + customName = this.customName, + highlightsJson = null, + fileSize = this.fileSize + ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index ae32e33..7cf9be0 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -360,51 +360,57 @@ class RecentFilesRepository(private val context: Context) { } suspend fun markAsNotRecent(bookIds: List) = withContext(Dispatchers.IO) { - if (bookIds.isNotEmpty()) { - Timber.d("DeleteDebug: DAO - Marking ${bookIds.size} items as not recent.") - recentFileDao.markAsNotRecent(bookIds, System.currentTimeMillis()) + bookIds.chunked(900).forEach { chunk -> + if (chunk.isNotEmpty()) { + Timber.d("DeleteDebug: DAO - Marking ${chunk.size} items as not recent.") + recentFileDao.markAsNotRecent(chunk, System.currentTimeMillis()) + } } } suspend fun markAsDeleted(bookIds: List) = withContext(Dispatchers.IO) { - if (bookIds.isNotEmpty()) { - recentFileDao.markAsDeleted(bookIds, System.currentTimeMillis()) - Timber.d("DeleteDebug: DAO - Marked ${bookIds.size} items as deleted.") + bookIds.chunked(900).forEach { chunk -> + if (chunk.isNotEmpty()) { + recentFileDao.markAsDeleted(chunk, System.currentTimeMillis()) + Timber.d("DeleteDebug: DAO - Marked ${chunk.size} items as deleted.") + } } } suspend fun deleteFilePermanently(bookIds: List) = withContext(Dispatchers.IO) { if (bookIds.isEmpty()) return@withContext - val itemsToRemove = bookIds.mapNotNull { recentFileDao.getFileByBookId(it) } + bookIds.chunked(900).forEach { chunk -> + val itemsToRemove = chunk.mapNotNull { recentFileDao.getFileByBookId(it) } - if (itemsToRemove.isNotEmpty()) { - Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.") - itemsToRemove.forEach { item -> - item.coverImagePath?.let { deleteCachedCover(it) } - try { - item.uriString?.let { bookImporter.deleteBookByUriString(it) } - } catch (e: Exception) { - Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}") - } - - try { - pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete() - pdfRichTextRepository.getFileForSync(item.bookId).delete() - pageLayoutRepository.getLayoutFile(item.bookId).delete() - pdfTextBoxRepository.getFileForSync(item.bookId).delete() - pdfHighlightRepository.getFileForSync(item.bookId).delete() - - val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}") - if (cacheDir.exists()) cacheDir.deleteRecursively() - } catch (e: Exception) { - Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}") + if (itemsToRemove.isNotEmpty()) { + Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.") + itemsToRemove.forEach { item -> + item.coverImagePath?.let { deleteCachedCover(it) } + try { + item.uriString?.let { bookImporter.deleteBookByUriString(it) } + } catch (e: Exception) { + Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}") + } + + try { + pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete() + pdfRichTextRepository.getFileForSync(item.bookId).delete() + pageLayoutRepository.getLayoutFile(item.bookId).delete() + pdfTextBoxRepository.getFileForSync(item.bookId).delete() + pdfHighlightRepository.getFileForSync(item.bookId).delete() + + val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}") + if (cacheDir.exists()) cacheDir.deleteRecursively() + } catch (e: Exception) { + Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}") + } } + recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId }) + Timber.d("Permanently removed recent files from DB.") + } else { + Timber.w("DeleteDebug: DAO - Files not found for permanent deletion.") } - recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId }) - Timber.d("Permanently removed recent files from DB.") - } else { - Timber.w("DeleteDebug: DAO - Files not found for permanent deletion.") } } @@ -490,8 +496,10 @@ class RecentFilesRepository(private val context: Context) { suspend fun addRecentFiles(items: List) = withContext(Dispatchers.IO) { if (items.isEmpty()) return@withContext - val entities = items.map { it.toRecentFileEntity() } - recentFileDao.insertOrUpdateFiles(entities) + items.chunked(900).forEach { chunk -> + val entities = chunk.map { it.toRecentFileEntity() } + recentFileDao.insertOrUpdateFiles(entities) + } Timber.d("Batch inserted/updated ${items.size} recent files in DB.") } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt index af65937..dfe2092 100644 --- a/app/src/main/java/com/aryan/reader/epub/EpubParser.kt +++ b/app/src/main/java/com/aryan/reader/epub/EpubParser.kt @@ -220,7 +220,9 @@ class EpubParser(private val context: Context) { } } - val data = if (isEssential) outputFile.readBytes() else ByteArray(0) + val lowerName = entry.name.lowercase() + val isContainerOrOpf = lowerName.endsWith("container.xml") || lowerName.endsWith(".opf") + val data = if (isContainerOrOpf) outputFile.readBytes() else ByteArray(0) filesMap[entry.name] = EpubFile(absPath = entry.name, data = data) } } @@ -309,7 +311,7 @@ class EpubParser(private val context: Context) { } val chaptersFromSpine = if (parseContent) { - parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap) + parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap, extractionRoot) } else { emptyList() } @@ -476,7 +478,8 @@ class EpubParser(private val context: Context) { spine: Node, manifestItems: Map, filesContentMap: Map, - ncxMetadataMap: Map + ncxMetadataMap: Map, + extractionRoot: File ): List = withContext(Dispatchers.Default) { val parsingSemaphore = Semaphore(6) @@ -489,7 +492,9 @@ class EpubParser(private val context: Context) { val idRef = itemRef.getAttribute("idref") val item = manifestItems[idRef] ?: return@withPermit null - val fileBytes = filesContentMap[item.absPath]?.data ?: return@withPermit null + val fileBytes = filesContentMap[item.absPath]?.data?.takeIf { it.isNotEmpty() } + ?: File(extractionRoot, item.absPath).takeIf { it.exists() }?.readBytes() + ?: return@withPermit null val mediaType = item.mediaType val absPath = item.absPath diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt index 3c6f086..f029d6a 100644 --- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt +++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt @@ -239,18 +239,7 @@ class SingleFileImporter(private val context: Context) { val fileName = "page_$pageNum.html" val file = File(extractionDir, fileName) - val fullHtml = """ - - - - $chapterTitle - - - - $htmlBody - - - """.trimIndent() + val fullHtml = "\n\n\n$chapterTitle\n\n\n\n$htmlBody\n\n" file.writeText(fullHtml) @@ -355,18 +344,7 @@ class SingleFileImporter(private val context: Context) { val file = File(extractionDir, fileName) val chapterTitle = "Part $chapterCounter" - val fullHtml = """ - - - - $chapterTitle - - - - $currentChapterContent - - - """.trimIndent() + val fullHtml = "\n\n\n$chapterTitle\n\n\n\n$currentChapterContent\n\n" FileOutputStream(file).use { it.write(fullHtml.toByteArray()) } @@ -692,20 +670,23 @@ class SingleFileImporter(private val context: Context) { Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms") - val fullHtml = """ - - - - ${originalBookNameHint.substringBeforeLast(".")} - - - $htmlContent - - - """.trimIndent() + val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html") + try { + FileOutputStream(tempFile).bufferedWriter().use { writer -> + val title = originalBookNameHint.substringBeforeLast(".") + writer.write("\n\n\n$title\n\n\n") + writer.write(htmlContent) + writer.write("\n\n") + } - // 4. Delegate to the already built HTML caching and chunking mechanisms! - return@withContext parseHtml(fullHtml.byteInputStream(), originalBookNameHint, bookId, parseContent) + tempFile.inputStream().use { tempStream -> + return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent) + } + } finally { + if (tempFile.exists()) { + tempFile.delete() + } + } } private fun writeHtmlChapter( @@ -720,18 +701,7 @@ class SingleFileImporter(private val context: Context) { val fileName = "page_$pageNum.html" val file = File(extractionDir, fileName) - val fullHtml = """ - - - - ${title.replace("\"", """)} - - - - ${bodyContent.trim()} - - - """.trimIndent() + val fullHtml = "\n\n\n${title.replace("\"", """)}\n\n\n\n${bodyContent.trim()}\n\n" file.writeText(fullHtml) diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt index c0f3781..620eeca 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult +import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.R -import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager import com.aryan.reader.epub.EpubBook @@ -55,6 +55,8 @@ import java.net.URL */ suspend fun summarizeBookContent( content: String, + authToken: String?, + onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> }, onUpdate: (String) -> Unit, onError: (String) -> Unit, onFinish: () -> Unit @@ -74,6 +76,9 @@ suspend fun summarizeBookContent( connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Accept", "application/json") + if (authToken != null) { + connection.setRequestProperty("Authorization", "Bearer $authToken") + } connection.connectTimeout = 15000 connection.readTimeout = 120000 connection.doOutput = true @@ -88,16 +93,29 @@ suspend fun summarizeBookContent( } val responseCode = connection.responseCode - Timber.d("Summarization: Got response code $responseCode") + + if (responseCode == 402) { + onError("INSUFFICIENT_CREDITS") + onFinish() + return@withContext + } 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("Summarization: Received line: $line") try { val jsonResponse = JSONObject(line!!) + + val cost = if (jsonResponse.has("cost_deducted")) jsonResponse.optDouble("cost_deducted", -1.0) else -1.0 + val freeRemaining = jsonResponse.optInt("free_summaries_remaining", -1) + if (cost > -1.0 || freeRemaining > -1) { + val finalCost = if (cost > -1.0) cost else null + val finalRemaining = if (freeRemaining > -1) freeRemaining else null + onUsageReceived(finalCost, finalRemaining) + } + jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { onUpdate(it) hasReceivedData = true @@ -137,6 +155,7 @@ suspend fun summarizeBookContent( * Fetches past summaries from cache/network and combines with current context. */ suspend fun executeRecapLogic( + authToken: String?, epubBook: EpubBook, chapterIndex: Int, characterLimit: Int, @@ -145,6 +164,7 @@ suspend fun executeRecapLogic( context: Context, onProgressUpdate: (String) -> Unit, onResultUpdate: (String) -> Unit, + onCostReceived: (Double?) -> Unit = {}, onError: (String) -> Unit, onFinish: () -> Unit ) { @@ -176,18 +196,38 @@ suspend fun executeRecapLogic( summarizeBookContent( content = textToSummarize, + authToken = authToken, + onUsageReceived = { cost, _ -> + Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits") + }, onUpdate = { sb.append(it) }, onError = { Timber.e("Failed to summarize Ch $i for recap: $it") latch.complete(false) }, - onFinish = { latch.complete(true) } + onFinish = { + latch.complete(true) + val summary = sb.toString() + if (summary.isNotBlank()) { + val chapterTitle = chapters.getOrNull(i)?.title ?: "Chapter ${i + 1}" + summaryCacheManager.saveSummary(epubBook.title, i, chapterTitle, summary) + pastSummaries.add(summary) + } + } ) val success = latch.await() if (success && sb.isNotEmpty()) { val summary = sb.toString() - summaryCacheManager.saveSummary(epubBook.title, i, summary) + + val chapterTitle = chapters.getOrNull(i)?.title ?: "Chapter ${i + 1}" + + summaryCacheManager.saveSummary( + bookTitle = epubBook.title, + chapterIndex = i, + chapterTitle = chapterTitle, + summary = summary + ) pastSummaries.add(summary) } } @@ -223,7 +263,9 @@ suspend fun executeRecapLogic( pastSummaries = pastSummaries, currentText = finalContextText, context = context, + authToken = authToken, onUpdate = { chunk -> onResultUpdate(chunk) }, + onCostReceived = onCostReceived, onError = { error -> onError(error) }, onFinish = { onFinish() } ) @@ -234,16 +276,22 @@ suspend fun executeRecapLogic( */ @Composable fun EpubReaderAiOverlays( - showSummarizationPopup: Boolean, + bookTitle: String, + currentChapterIndex: Int, + chapterTitle: String, + summaryCacheManager: SummaryCacheManager, + showAiHubSheet: Boolean, summarizationResult: SummarizationResult?, isSummarizationLoading: Boolean, - onDismissSummarization: () -> Unit, - showSummarizationUpsellDialog: Boolean, - onDismissSummarizationUpsell: () -> Unit, - showRecapPopup: Boolean, + onGenerateSummary: (Boolean) -> Unit, recapResult: SummarizationResult?, isRecapLoading: Boolean, - onDismissRecap: () -> Unit, + onGenerateRecap: () -> Unit, + onDismissAiHub: () -> Unit, + onClearSummary: () -> Unit = {}, + onClearRecap: () -> Unit = {}, + showSummarizationUpsellDialog: Boolean, + onDismissSummarizationUpsell: () -> Unit, showAiDefinitionPopup: Boolean, selectedTextForAi: String?, aiDefinitionResult: AiDefinitionResult?, @@ -253,25 +301,30 @@ fun EpubReaderAiOverlays( onDismissDictionaryUpsell: () -> Unit, onNavigateToPro: () -> Unit, isTtsSessionActive: Boolean, - onOpenExternalDictionary: (String) -> Unit + onOpenExternalDictionary: (String) -> Unit, + getAuthToken: suspend () -> String?, + credits: Int, + isProUser: Boolean ) { - if (showSummarizationPopup) { - SummarizationPopup( - title = stringResource(R.string.ai_chapter_summary), - result = summarizationResult, - isLoading = isSummarizationLoading, - onDismiss = onDismissSummarization, - isMainTtsActive = isTtsSessionActive - ) - } - - if (showRecapPopup) { - SummarizationPopup( - title = stringResource(R.string.ai_story_recap_beta), - result = recapResult, - isLoading = isRecapLoading, - onDismiss = onDismissRecap, + if (showAiHubSheet) { + AiHubBottomSheet( + bookTitle = bookTitle, + currentChapterIndex = currentChapterIndex, + chapterTitle = chapterTitle, + summaryCacheManager = summaryCacheManager, + summarizationResult = summarizationResult, + isSummarizationLoading = isSummarizationLoading, + onGenerateSummary = onGenerateSummary, + recapResult = recapResult, + isRecapLoading = isRecapLoading, + onGenerateRecap = onGenerateRecap, + onDismiss = onDismissAiHub, + onClearSummary = onClearSummary, + onClearRecap = onClearRecap, isMainTtsActive = isTtsSessionActive, + getAuthToken = getAuthToken, + credits = credits, + isProUser = isProUser ) } @@ -312,7 +365,8 @@ fun EpubReaderAiOverlays( isMainTtsActive = isTtsSessionActive, onOpenExternalDictionary = { selectedTextForAi?.let { text -> onOpenExternalDictionary(text) } - } + }, + getAuthToken = getAuthToken ) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt index 3ea0081..3cac01b 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt @@ -62,9 +62,9 @@ suspend fun loadChapterContent( val (headContent, chunks) = if (htmlFile.exists()) { val doc = Jsoup.parse(htmlFile, "UTF-8") val head = doc.head().html() - val bodyChildren = doc.body().children().toList() - val chunkedList = bodyChildren.chunked(20).map { chunkOfElements -> - chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } + val bodyNodes = doc.body().childNodes().toList() + val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes -> + chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() } } if (chunkedList.isEmpty()) { head to listOf("

${context.getString(R.string.chapter_empty)}

") diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index dc529db..870ce0d 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -24,7 +24,6 @@ import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.os.Build -import android.speech.tts.TextToSpeech import android.webkit.WebView import androidx.annotation.RequiresApi import androidx.compose.animation.AnimatedContent @@ -87,9 +86,7 @@ import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -101,14 +98,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -140,6 +134,7 @@ import com.aryan.reader.epub.EpubChapter import com.aryan.reader.loadNativeVoice import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.IPaginator +import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS import com.aryan.reader.tts.TtsPlaybackManager.TtsState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -188,7 +183,6 @@ fun EpubReaderTopBar( onTogglePageTurnAnimation: (Boolean) -> Unit, onStartAutoScroll: () -> Unit, onOpenTtsSettings: () -> Unit, - onOpenDeviceVoiceSettings: () -> Unit, onOpenDictionarySettings: () -> Unit, onOpenThemeSettings: () -> Unit, onOpenVisualOptions: () -> Unit, @@ -468,9 +462,10 @@ fun EpubReaderTopBar( if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) { DropdownMenuItem( text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsActive, onClick = { showMoreMenu = false - onOpenDeviceVoiceSettings() + onOpenTtsSettings() }, leadingIcon = { Icon( @@ -478,24 +473,8 @@ fun EpubReaderTopBar( contentDescription = null, modifier = Modifier.size(20.dp) ) - }) - - if (BuildConfig.DEBUG) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_settings_debug)) }, - onClick = { - showMoreMenu = false - onOpenTtsSettings() - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.text_to_speech), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - ) - } + } + ) } } } @@ -514,15 +493,12 @@ fun EpubReaderBottomBar( ttsState: TtsState, isProUser: Boolean, currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, - onOpenTtsControls: () -> Unit, onOpenSlider: () -> Unit, onOpenDrawer: () -> Unit, onToggleFormat: () -> Unit, onToggleSearch: () -> Unit, - onSummarize: () -> Unit, - onRecap: () -> Unit, + onOpenAiHub: () -> Unit, onToggleTts: () -> Unit, - onPlayPauseTts: () -> Unit, hiddenTools: Set, modifier: Modifier = Modifier ) { @@ -600,90 +576,36 @@ fun EpubReaderBottomBar( "KotlinConstantConditions", "SimplifyBooleanWithConstants" ) if (BuildConfig.FLAVOR != "oss") { - Box { - var showAiFeaturesMenu by remember { mutableStateOf(false) } - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = { showAiFeaturesMenu = true }) { - Icon( - painter = painterResource(id = R.drawable.ai), - contentDescription = "AI Features" - ) - } - DropdownMenu( - expanded = showAiFeaturesMenu, - onDismissRequest = { showAiFeaturesMenu = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_chapter_summarization)) }, - onClick = { - showAiFeaturesMenu = false - onSummarize() - }) - if (BuildConfig.DEBUG && isProUser) { - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_recap_beta)) }, - onClick = { - showAiFeaturesMenu = false - onRecap() - }) - } - } + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = onOpenAiHub + ) { + Icon( + painter = painterResource(id = R.drawable.ai), + contentDescription = "AI Features" + ) } } } if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) { - Box { - Row(verticalAlignment = Alignment.CenterVertically) { - TooltipIconButton( - text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) - else stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) - else stringResource(R.string.tooltip_tts_start_desc), - onClick = onToggleTts - ) { - Icon( - painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource( - id = R.drawable.text_to_speech - ), - contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource( - R.string.content_desc_start_tts - ) - ) - } - if (isTtsSessionActive) { - TooltipIconButton( - text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause) - else stringResource(R.string.tooltip_tts_resume), - description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc) - else stringResource(R.string.tooltip_tts_resume_desc), - onClick = onPlayPauseTts, - enabled = !ttsState.isLoading - ) { - Icon( - painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), - contentDescription = if (ttsState.isPlaying) stringResource( - R.string.content_desc_pause_tts - ) else stringResource(R.string.content_desc_resume_tts) - ) - } - - if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.BASE) { - TooltipIconButton( - text = "Voice Adjustments", - description = "Adjust voice speed and pitch", - onClick = onOpenTtsControls - ) { - Icon( - imageVector = Icons.Default.Tune, - contentDescription = "Voice Adjustments" - ) - } - } - } - } + TooltipIconButton( + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) + else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) + else stringResource(R.string.tooltip_tts_start_desc), + onClick = onToggleTts + ) { + Icon( + painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource( + id = R.drawable.text_to_speech + ), + contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource( + R.string.content_desc_start_tts + ), + tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) } } } @@ -1450,194 +1372,220 @@ fun CustomizeToolsSheet( @androidx.annotation.OptIn(UnstableApi::class) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TtsControlsSheet( - onDismiss: () -> Unit, - onOpenDeviceVoiceSettings: () -> Unit, - ttsController: com.aryan.reader.tts.TtsController +fun TtsOverlayControls( + ttsController: com.aryan.reader.tts.TtsController, + ttsState: TtsState, + currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, + isCollapsed: Boolean, + onCollapseChange: (Boolean) -> Unit, + onOpenTtsSettings: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + credits: Int ) { val context = androidx.compose.ui.platform.LocalContext.current - val ttsState by ttsController.ttsState.collectAsState() - - // Local TTS for Sample Playback - var tts by remember { mutableStateOf(null) } - var isTtsReady by remember { mutableStateOf(false) } - var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) } var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) } - var isDraggingRate by remember { mutableStateOf(false) } var isDraggingPitch by remember { mutableStateOf(false) } - // Initialize Local TTS for samples - DisposableEffect(Unit) { - val instance = TextToSpeech(context) { status -> - if (status == TextToSpeech.SUCCESS) { - isTtsReady = true - try { - val preferredVoiceName = loadNativeVoice(context) - if (preferredVoiceName != null) { - tts?.voices?.find { it.name == preferredVoiceName }?.let { targetVoice -> - tts?.voice = targetVoice - } - } - } catch (e: Exception) { - Timber.e(e, "Failed to apply preferred voice in sample") - } - } - } - tts = instance - onDispose { instance.shutdown() } - } + val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD } - val saveAndSlice = { + val saveAndApply = { saveTtsSpeechRate(context, rate) saveTtsPitch(context, pitch) - ttsController.sliceAndRetainPosition() + if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + ttsController.setPlaybackParameters(rate, pitch) + } else { + ttsController.sliceAndRetainPosition() + } } - val ttsSample = stringResource(R.string.tts_sample_text) + val backgroundAlpha = 0.6f - ModalBottomSheet( - onDismissRequest = onDismiss, - contentWindowInsets = { WindowInsets.navigationBars } + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)), + modifier = modifier.widthIn(max = 400.dp).animateContentSize() ) { - Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) { - Text(stringResource(R.string.tts_voice_adjustments), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(16.dp)) - - // Rate Slider - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.tts_speed_label, "%.1f".format(rate)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - IconButton(onClick = { - rate = 1.0f - ttsController.pause() - saveAndSlice() - }) { - Icon(Icons.Default.Refresh, contentDescription = "Reset Speed") - } - } - Slider( - value = rate, - onValueChange = { - rate = it - // Pause playback immediately when user starts dragging - if (!isDraggingRate) { - isDraggingRate = true - ttsController.pause() - } - }, - onValueChangeFinished = { - isDraggingRate = false - saveAndSlice() - }, - valueRange = 0.5f..3.0f, - steps = 24 // Creates 0.1 increments - ) - - // Pitch Slider - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.tts_pitch_label, "%.1f".format(pitch)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - IconButton(onClick = { - pitch = 1.0f - ttsController.pause() - saveAndSlice() - }) { - Icon(Icons.Default.Refresh, contentDescription = "Reset Pitch") - } - } - Slider( - value = pitch, - onValueChange = { - pitch = it - if (!isDraggingPitch) { - isDraggingPitch = true - ttsController.pause() - } - }, - onValueChangeFinished = { - isDraggingPitch = false - saveAndSlice() - }, - valueRange = 0.5f..2.0f, - steps = 14 // Creates 0.1 increments - ) - - Spacer(Modifier.height(8.dp)) - - // Play Sample Button - Button( - onClick = { - if (ttsState.isPlaying) ttsController.pause() - tts?.setSpeechRate(rate) - tts?.setPitch(pitch) - tts?.speak(ttsSample, TextToSpeech.QUEUE_FLUSH, null, null) - }, - modifier = Modifier.fillMaxWidth(), - enabled = isTtsReady, - colors = androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - Icon(Icons.Default.GraphicEq, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Play Sample") - } - - Spacer(Modifier.height(24.dp)) - - // Central Play/Pause Control for the Book - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - FilledIconButton( - onClick = { - tts?.stop() - if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() - }, - modifier = Modifier.size(64.dp), - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) + AnimatedContent( + targetState = isCollapsed, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) }, + label = "TtsOverlayUnified" + ) { collapsed -> + if (collapsed) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton( + onClick = { onCollapseChange(false) }, + modifier = Modifier.size(36.dp) ) { - if (ttsState.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - strokeWidth = 3.dp + Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() }, + modifier = Modifier.size(36.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary ) - } else { + ) { Icon( - painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), - contentDescription = if (ttsState.isPlaying) stringResource(R.string.tts_pause_book) else stringResource(R.string.tts_resume_book), - modifier = Modifier.size(32.dp) + painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + "Play/Pause", + modifier = Modifier.size(20.dp) ) } + if (ttsState.isLoading) CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f), + strokeWidth = 2.dp + ) } - Spacer(Modifier.height(8.dp)) - Text( - text = if (ttsState.isPlaying) stringResource(R.string.tts_pause_book) else stringResource(R.string.tts_resume_book), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } - } + } else { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } - Spacer(Modifier.height(24.dp)) - OutlinedButton( - onClick = { - onDismiss() - onOpenDeviceVoiceSettings() - }, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.Settings, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.tts_system_settings)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f), + shape = RoundedCornerShape(8.dp) + ) { + val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId + } else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default" + + Text( + voiceName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).widthIn(max = 100.dp) + ) + } + + if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + "⭐ $credits", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, "Stop TTS", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp)) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Middle Section: Controls + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Giant Play/Pause + Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() }, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), + "Play/Pause", + modifier = Modifier.size(28.dp) + ) + } + if (ttsState.isLoading) CircularProgressIndicator( + modifier = Modifier.size(56.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + strokeWidth = 3.dp + ) + } + + Spacer(Modifier.width(16.dp)) + + // Unified Sliders Block + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Spd: %.1fx".format(rate), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp)) + Slider( + value = rate, + onValueChange = { + rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + isDraggingRate = true; ttsController.pause() + } + }, + onValueChangeFinished = { isDraggingRate = false; saveAndApply() }, + valueRange = 0.5f..3.0f, + steps = 24, + modifier = Modifier.weight(1f).height(24.dp) + ) + IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Refresh, "Reset Speed", modifier = Modifier.size(16.dp)) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Ptch: %.1fx".format(pitch), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp)) + Slider( + value = pitch, + onValueChange = { + pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) { + isDraggingPitch = true; ttsController.pause() + } + }, + onValueChangeFinished = { isDraggingPitch = false; saveAndApply() }, + valueRange = 0.5f..2.0f, + steps = 14, + modifier = Modifier.weight(1f).height(24.dp) + ) + IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Refresh, "Reset Pitch", modifier = Modifier.size(16.dp)) + } + } + } + } + } } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt index 9a644ec..29eb307 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderDrawer.kt @@ -354,63 +354,113 @@ private fun ChaptersList( result } - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxHeight().padding(end = 12.dp) - ) { - items( - items = visibleItemInfo, - key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" } - ) { (originalIndex, entry) -> - val nextItem = effectiveToc.getOrNull(originalIndex + 1) - val hasChildren = nextItem != null && nextItem.depth > entry.depth - val isExpanded = expandedEntryIndices.contains(originalIndex) + val coroutineScope = rememberCoroutineScope() - // HIGHLIGHT LOGIC FIXED - val isCurrentPath = currentChapterPath == entry.absolutePath - val matchesFragment = entry.fragmentId == activeFragmentId + val activeTocEntry = remember(effectiveToc, currentChapterPath, activeFragmentId, firstEntryForCurrentChapter) { + effectiveToc.find { + it.absolutePath == currentChapterPath && it.fragmentId == activeFragmentId + } ?: firstEntryForCurrentChapter + } - // Fallback logic - val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter - val isHighlighting = isCurrentPath && (matchesFragment || isFallback) - - if (isCurrentPath) { - Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback") - } - - if (isCurrentPath) { - Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting") - } - - TocTreeItem( - label = entry.label, - depth = entry.depth, - isExpanded = isExpanded, - hasChildren = hasChildren, - isCurrent = isHighlighting, - onToggleExpand = { - expandedEntryIndices = if (isExpanded) { - expandedEntryIndices - originalIndex - } else { - expandedEntryIndices + originalIndex - } - }, - onClick = { - if (tocEntries.isEmpty()) { - onNavigateToChapter(originalIndex) - } else { - onNavigateToTocEntry(entry) - } + val onScrollToCurrent = { + coroutineScope.launch { + val targetEntry = activeTocEntry ?: return@launch + val targetOriginalIndex = effectiveToc.indexOf(targetEntry) + if (targetOriginalIndex != -1) { + // Ensure parents are expanded + var currentLevel = targetEntry.depth + val newExpanded = expandedEntryIndices.toMutableSet() + for (i in targetOriginalIndex downTo 0) { + val entry = effectiveToc[i] + if (entry.depth < currentLevel) { + newExpanded.add(i) + currentLevel = entry.depth } - ) + } + expandedEntryIndices = newExpanded + + // Delay to allow visibility array to recompose + kotlinx.coroutines.delay(100) + + val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry } + if (visibleIdx != -1) { + listState.animateScrollToItem(visibleIdx) + } + } + } + Unit + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { expandedEntryIndices = effectiveToc.indices.toSet() }) { + Text("Expand All") + } + TextButton(onClick = { expandedEntryIndices = emptySet() }) { + Text("Collapse All") + } + TextButton(onClick = onScrollToCurrent) { + Text("Locate") } } - VerticalScrollbar( - listState = listState, - modifier = Modifier.align(Alignment.CenterEnd) - ) + HorizontalDivider() + + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxHeight() + .padding(end = 12.dp) + ) { + items( + items = visibleItemInfo, + key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" } + ) { (originalIndex, entry) -> + val nextItem = effectiveToc.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.depth > entry.depth + val isExpanded = expandedEntryIndices.contains(originalIndex) + + val isCurrentPath = currentChapterPath == entry.absolutePath + val matchesFragment = entry.fragmentId == activeFragmentId + + val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter + val isHighlighting = isCurrentPath && (matchesFragment || isFallback) + + TocTreeItem( + label = entry.label, + depth = entry.depth, + isExpanded = isExpanded, + hasChildren = hasChildren, + isCurrent = isHighlighting, + onToggleExpand = { + expandedEntryIndices = if (isExpanded) { + expandedEntryIndices - originalIndex + } else { + expandedEntryIndices + originalIndex + } + }, + onClick = { + if (tocEntries.isEmpty()) { + onNavigateToChapter(originalIndex) + } else { + onNavigateToTocEntry(entry) + } + } + ) + } + } + + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } } } 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 8d35ef4..625a174 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -27,6 +27,8 @@ package com.aryan.reader.epubreader import android.Manifest import android.annotation.SuppressLint import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap @@ -128,6 +130,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -147,7 +150,6 @@ import com.aryan.reader.BannerMessage import com.aryan.reader.BuildConfig import com.aryan.reader.BuiltInThemes import com.aryan.reader.CustomTopBanner -import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.MainViewModel import com.aryan.reader.R import com.aryan.reader.ReaderThemePanel @@ -180,6 +182,7 @@ import com.aryan.reader.rememberSearchState import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveReaderThemeId import com.aryan.reader.tts.SpeakerSamplePlayer +import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks @@ -416,6 +419,7 @@ fun EpubReaderScreen( initialBookmarksJson = initialBookmarksJson, initialHighlightsJson = uiState.initialHighlightsJson, isProUser = isProUser, + credits = uiState.credits, onNavigateBack = onNavigateBack, onSavePosition = onSavePosition, onBookmarksChanged = onBookmarksChanged, @@ -438,7 +442,8 @@ fun EpubReaderScreen( } } } - } else null + } else null, + viewModel = viewModel ) } @@ -456,6 +461,7 @@ fun EpubReaderHost( initialBookmarksJson: String?, initialHighlightsJson: String?, isProUser: Boolean, + credits: Int, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, @@ -466,7 +472,8 @@ fun EpubReaderHost( customFonts: List, onImportFont: (Uri) -> Unit, onToggleReflow: ((Int) -> Unit)? = null, - onDeleteReflow: (() -> Unit)? = null + onDeleteReflow: (() -> Unit)? = null, + viewModel: MainViewModel ) { val view = LocalView.current val context = LocalContext.current @@ -479,6 +486,7 @@ fun EpubReaderHost( val containerFocusRequester = remember { FocusRequester() } var isNavigatingToPosition by remember { mutableStateOf(false) } var isSeamlessTransitioning by remember { mutableStateOf(false) } + var showInsufficientCreditsDialog by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } @@ -516,7 +524,13 @@ fun EpubReaderHost( mutableStateOf(loadPageTurnAnimationSetting(context)) } - var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } + var currentTtsMode by remember { + mutableStateOf( + loadTtsMode(context).let { + if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it + } + ) + } val locatorConverter = remember(context) { LocatorConverter( @@ -546,6 +560,7 @@ fun EpubReaderHost( } var isAutoScrollCollapsed by remember { mutableStateOf(false) } + var isTtsCollapsed by remember { mutableStateOf(false) } val bookId = remember(epubBook.title, epubBook.fileName) { if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) @@ -679,24 +694,35 @@ fun EpubReaderHost( if (effectiveUseOnline) { val wordCount = countWords(word) - if (isProUser || wordCount <= 1) { + if (wordCount > 1 && !isProUser) { + showDictionaryUpsellDialog = true + } else { selectedTextForAi = word showAiDefinitionPopup = true scope.launch { + val token = viewModel.getAuthToken() isAiDefinitionLoading = true aiDefinitionResult = null fetchAiDefinition( - text = word, onUpdate = { chunk -> - val currentDefinition = aiDefinitionResult?.definition ?: "" - aiDefinitionResult = - AiDefinitionResult(definition = currentDefinition + chunk) - }, onError = { error -> - aiDefinitionResult = AiDefinitionResult(error = error) - }, onFinish = { isAiDefinitionLoading = false }, context = context + text = word, + onUpdate = { chunk -> + val currentDefinition = aiDefinitionResult?.definition ?: "" + aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk) + }, + authToken = token, + onError = { error -> + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiDefinitionPopup = false + isAiDefinitionLoading = false + } else { + aiDefinitionResult = AiDefinitionResult(error = error) + } + }, + onFinish = { isAiDefinitionLoading = false }, + context = context ) } - } else { - showDictionaryUpsellDialog = true } } else { if (!selectedDictPackage.isNullOrEmpty()) { @@ -728,10 +754,6 @@ fun EpubReaderHost( val summaryCacheManager = remember(context) { SummaryCacheManager(context) } var showRecapPopup by remember { mutableStateOf(false) } - var recapResult by remember { mutableStateOf(null) } - var isRecapLoading by remember { mutableStateOf(false) } - var recapProgressMessage by remember { mutableStateOf("") } - var isRequestingRecapCfi by remember { mutableStateOf(false) } var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) } var chapterToLoadOnSwitch by remember { mutableStateOf(null) } @@ -796,10 +818,15 @@ fun EpubReaderHost( var webViewRefForTts by remember { mutableStateOf(null) } - var showSummarizationPopup by remember { mutableStateOf(false) } + var showAiHubSheet by remember { mutableStateOf(false) } var summarizationResult by remember { mutableStateOf(null) } var isSummarizationLoading by remember { mutableStateOf(false) } + var recapResult by remember { mutableStateOf(null) } + var isRecapLoading by remember { mutableStateOf(false) } + var recapProgressMessage by remember { mutableStateOf("") } + var isRequestingRecapCfi by remember { mutableStateOf(false) } + val epubSearcher = remember(epubBook) { createEpubSearcher(epubBook) } val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -964,7 +991,12 @@ fun EpubReaderHost( LaunchedEffect(ttsState.errorMessage) { ttsState.errorMessage?.let { message -> - bannerMessage = BannerMessage(message, isError = true) + if (message == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + ttsController.stop() + } else { + bannerMessage = BannerMessage(message, isError = true) + } } } @@ -983,7 +1015,9 @@ fun EpubReaderHost( } val searchState = rememberSearchState(scope = scope, searcher = epubSearcher) - val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) } + val speakerPlayer = remember(context, scope) { + SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() }) + } var isAutoScrollModeActive by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) } @@ -1041,7 +1075,6 @@ fun EpubReaderHost( var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) } - var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) } @@ -1102,6 +1135,11 @@ fun EpubReaderHost( } fun startTts() { + if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + showInsufficientCreditsDialog = true + return + } + if (isAutoScrollModeActive) { isAutoScrollModeActive = false isAutoScrollPlaying = false @@ -1114,6 +1152,7 @@ fun EpubReaderHost( webView = webViewRefForTts, onPaginatedStart = { scope.launch { + val token = viewModel.getAuthToken() val currentPage = paginatedPagerState.currentPage val bookPaginator = paginator as? BookPaginator val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage) @@ -1136,7 +1175,8 @@ fun EpubReaderHost( chapterTitle = chapterTitle, coverImageUri = coverUriString, ttsMode = currentTtsMode, - playbackSource = "READER" + playbackSource = "READER", + authToken = token ) } } @@ -1153,8 +1193,14 @@ fun EpubReaderHost( ) fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { + if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + showInsufficientCreditsDialog = true + return + } + val action = { scope.launch { + val token = viewModel.getAuthToken() val bookPaginator = paginator as? BookPaginator val chapterIndex = currentChapterInPaginatedMode ?: return@launch val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch @@ -1191,7 +1237,8 @@ fun EpubReaderHost( chapterTitle = chapterTitle, coverImageUri = coverUriString, ttsMode = currentTtsMode, - playbackSource = "READER" + playbackSource = "READER", + authToken = token ) } } @@ -1237,7 +1284,8 @@ fun EpubReaderHost( onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, userStoppedTts = userStoppedTts, scope = scope, - currentTtsMode = currentTtsMode + currentTtsMode = currentTtsMode, + getAuthToken = { viewModel.getAuthToken() } ) TtsHighlightHandler( @@ -1338,12 +1386,15 @@ fun EpubReaderHost( } val runRecap = { chapterIdx: Int, charLimit: Int -> - showRecapPopup = true + showAiHubSheet = true isRecapLoading = true recapResult = null recapProgressMessage = "Checking past chapters..." scope.launch { + val token = viewModel.getAuthToken() + var currentCost: Double? = null + executeRecapLogic( epubBook = epubBook, chapterIndex = chapterIdx, @@ -1352,13 +1403,27 @@ fun EpubReaderHost( paginator = paginator, context = context, onProgressUpdate = { recapProgressMessage = it }, + onCostReceived = { cost -> + currentCost = cost + recapResult = recapResult?.copy(cost = cost) ?: SummarizationResult(cost = cost) + }, onResultUpdate = { chunk -> isRecapLoading = false val current = recapResult?.summary ?: "" - recapResult = SummarizationResult(summary = current + chunk) + recapResult = SummarizationResult( + summary = current + chunk, + cost = currentCost + ) }, + authToken = token, onError = { error -> - recapResult = SummarizationResult(error = error) + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showRecapPopup = false + isRecapLoading = false + } else { + recapResult = SummarizationResult(error = error) + } }, onFinish = { isRecapLoading = false } ) @@ -2053,6 +2118,161 @@ fun EpubReaderHost( } } + val handleGenerateSummary: (Boolean) -> Unit = { force -> + if (!isProUser && credits <= 0) { + showInsufficientCreditsDialog = true + showAiHubSheet = false + } else { + showAiHubSheet = true + isSummarizationLoading = true + summarizationResult = null + when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> { + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + currentChapterIndex + ) else null + if (cached != null) { + summarizationResult = + SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + } else { + webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> + Timber.d("JS summarization request: $result") + } ?: run { + isSummarizationLoading = false + summarizationResult = + SummarizationResult(error = "WebView not available.") + } + } + } + + RenderMode.PAGINATED -> { + scope.launch { + val currentPage = paginatedPagerState.currentPage + val token = viewModel.getAuthToken() + val chapterIndex = + (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage) + + Timber.tag("POS_DIAG") + .d("handleGenerateSummary (Paginated): currentPage=$currentPage -> resolved chapterIndex=$chapterIndex") + + if (chapterIndex != null) { + val cached = if (!force) summaryCacheManager.getSummary( + epubBook.title, + chapterIndex + ) else null + if (cached != null) { + summarizationResult = + SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + return@launch + } + + val text = paginator?.getPlainTextForChapter(chapterIndex) + if (!text.isNullOrBlank()) { + var currentCost: Double? = null + var currentFreeRemaining: Int? = null + val finalSummaryBuilder = StringBuilder() + summarizeBookContent( + content = text, + authToken = token, + onUsageReceived = { cost, freeRemaining -> + currentCost = cost + currentFreeRemaining = freeRemaining + summarizationResult = summarizationResult?.copy( + cost = cost, freeRemaining = freeRemaining + ) ?: SummarizationResult( + cost = cost, + freeRemaining = freeRemaining + ) + }, + onUpdate = { chunk -> + finalSummaryBuilder.append(chunk) + val currentSummary = summarizationResult?.summary ?: "" + summarizationResult = SummarizationResult( + summary = currentSummary + chunk, + cost = currentCost, + freeRemaining = currentFreeRemaining + ) + }, + onError = { error -> + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiHubSheet = false + isSummarizationLoading = false + } else { + summarizationResult = + SummarizationResult(error = error) + } + }, + onFinish = { + isSummarizationLoading = false + val fullSummary = finalSummaryBuilder.toString() + if (fullSummary.isNotBlank()) { + val chapterTitle = + chapters.getOrNull(chapterIndex)?.title + ?: "Chapter ${chapterIndex + 1}" + summaryCacheManager.saveSummary( + epubBook.title, + chapterIndex, + chapterTitle, + fullSummary + ) + } + }) + } else { + summarizationResult = + SummarizationResult(error = "Could not get chapter content.") + isSummarizationLoading = false + } + } else { + summarizationResult = + SummarizationResult(error = "Could not determine current chapter.") + isSummarizationLoading = false + } + } + } + } + } + } + + val handleGenerateRecap: () -> Unit = { + if (credits <= 0) { + showInsufficientCreditsDialog = true + showAiHubSheet = false + } else { + showAiHubSheet = true + when (currentRenderMode) { + RenderMode.VERTICAL_SCROLL -> { + isRequestingRecapCfi = true + webViewRefForTts?.evaluateJavascript( + "javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", + null + ) + } + + RenderMode.PAGINATED -> { + val bookPaginator = paginator as? BookPaginator + val chapterIndex = currentChapterInPaginatedMode + + if (bookPaginator != null && chapterIndex != null) { + val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 + val currentPageInChapter = paginatedPagerState.currentPage - startPage + val charsScrolled = bookPaginator.getCharactersScrolledInChapter( + chapterIndex, + currentPageInChapter + ) + runRecap(chapterIndex, charsScrolled.toInt()) + } else { + bannerMessage = + BannerMessage("Wait for book to load fully.", isError = true) + } + } + } + } + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, contentWindowInsets = WindowInsets.statusBars, @@ -2552,6 +2772,7 @@ fun EpubReaderHost( ttsScope = scope, onTtsTextReady = { jsonString -> scope.launch { + val token = viewModel.getAuthToken() Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this val ttsChunks = mutableListOf() try { @@ -2587,9 +2808,14 @@ fun EpubReaderHost( Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") if (ttsChunks.isNotEmpty()) { + if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) { + showInsufficientCreditsDialog = true + ttsShouldStartOnChapterLoad = false + return@launch + } + ttsShouldStartOnChapterLoad = false - val chapterTitle = - chapters.getOrNull(currentChapterIndex)?.title + val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } @@ -2600,7 +2826,8 @@ fun EpubReaderHost( chapterTitle = chapterTitle, coverImageUri = coverUriString, ttsMode = currentTtsMode, - playbackSource = "READER" + playbackSource = "READER", + authToken = token ) } else { Timber.w("No TTS chunks were created from JSON, not starting TTS." @@ -2625,25 +2852,48 @@ fun EpubReaderHost( onContentReadyForSummarization = { content -> Timber.d("Content received for summarization") scope.launch { + val token = viewModel.getAuthToken() val chapterIndexToSave = currentChapterIndex val bookTitleToSave = epubBook.title val finalSummaryBuilder = StringBuilder() + var currentCost: Double? = null + var currentFreeRemaining: Int? = null + summarizeBookContent( content = content, + authToken = token, + onUsageReceived = { cost: Double?, freeRemaining: Int? -> + currentCost = cost + currentFreeRemaining = freeRemaining + summarizationResult = summarizationResult?.copy( + cost = cost, freeRemaining = freeRemaining + ) ?: SummarizationResult(cost = cost, freeRemaining = freeRemaining) + }, onUpdate = { chunk -> finalSummaryBuilder.append(chunk) val currentSummary = summarizationResult?.summary ?: "" - summarizationResult = SummarizationResult(summary = currentSummary + chunk) + summarizationResult = SummarizationResult( + summary = currentSummary + chunk, + cost = currentCost, + freeRemaining = currentFreeRemaining + ) }, onError = { error -> - summarizationResult = SummarizationResult(error = error) + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiHubSheet = false + isRecapLoading = false + } else { + recapResult = SummarizationResult(error = error) + } }, onFinish = { isSummarizationLoading = false val fullSummary = finalSummaryBuilder.toString() if (fullSummary.isNotBlank()) { - summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, fullSummary) + val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}" + summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary) } } ) @@ -3594,7 +3844,6 @@ fun EpubReaderHost( modifier = Modifier.align(Alignment.TopCenter), onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true }, - onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, onOpenThemeSettings = { showThemePanel = true }, onOpenVisualOptions = { showVisualOptionsSheet = true }, onToggleReflow = if (onToggleReflow != null) { @@ -3620,6 +3869,40 @@ fun EpubReaderHost( label = "AutoScrollAlignAnimation" ) + val ttsOverlayPadding by animateDpAsState( + targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp, + label = "TtsOverlayPadding" + ) + + val ttsAlignmentBias by animateFloatAsState( + targetValue = if (isTtsCollapsed) 1f else 0f, + label = "TtsAlignAnimation" + ) + + AnimatedVisibility( + visible = isTtsSessionActive && showBars, + enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), + exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), + modifier = Modifier + .align(BiasAlignment(ttsAlignmentBias, 1f)) + .padding(bottom = ttsOverlayPadding) + .padding(horizontal = 16.dp) + ) { + TtsOverlayControls( + ttsController = ttsController, + ttsState = ttsState, + currentTtsMode = currentTtsMode, + isCollapsed = isTtsCollapsed, + onCollapseChange = { isTtsCollapsed = it }, + onOpenTtsSettings = { showTtsSettingsSheet = true }, + onClose = { + userStoppedTts = true + ttsController.stop() + }, + credits = credits + ) + } + val isAutoScrollControlsVisible = isAutoScrollModeActive AnimatedVisibility( @@ -3725,7 +4008,7 @@ fun EpubReaderHost( isProUser = isProUser, hiddenTools = hiddenTools, currentTtsMode = currentTtsMode, - onOpenTtsControls = { showTtsControlsSheet = true }, + onOpenAiHub = { showAiHubSheet = true }, onOpenSlider = { when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { @@ -3768,90 +4051,6 @@ fun EpubReaderHost( showBars = true showFormatAdjustmentBars = false }, - onSummarize = { - if (isProUser) { - showSummarizationPopup = true - isSummarizationLoading = true - summarizationResult = null - when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> { - webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result -> - Timber.d("JS summarization request: $result") - } ?: run { - isSummarizationLoading = false - summarizationResult = SummarizationResult(error = "WebView not available.") - } - } - RenderMode.PAGINATED -> { - scope.launch { - val currentPage = paginatedPagerState.currentPage - val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage) - if (chapterIndex != null) { - val text = paginator?.getPlainTextForChapter(chapterIndex) - if (!text.isNullOrBlank()) { - summarizeBookContent( - content = text, - onUpdate = { chunk -> - val currentSummary = - summarizationResult?.summary - ?: "" - summarizationResult = - SummarizationResult( - summary = currentSummary + chunk - ) - }, - onError = { error -> - summarizationResult = - SummarizationResult( - error = error - ) - }, - onFinish = { - isSummarizationLoading = - false - } - ) - } else { - summarizationResult = SummarizationResult(error = "Could not get chapter content.") - isSummarizationLoading = false - } - } else { - summarizationResult = SummarizationResult(error = "Could not determine current chapter.") - isSummarizationLoading = false - } - } - } - } - } else { - showSummarizationUpsellDialog = true - } - }, - onRecap = { - when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> { - isRequestingRecapCfi = true - webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null) - } - RenderMode.PAGINATED -> { - val bookPaginator = paginator as? BookPaginator - val chapterIndex = currentChapterInPaginatedMode - - if (bookPaginator != null && chapterIndex != null) { - val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0 - val currentPageInChapter = paginatedPagerState.currentPage - startPage - - val charsScrolled = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter) - - Timber.d("Paginated Mode: Chapter $chapterIndex, PageInChapter $currentPageInChapter") - Timber.d("Paginated Mode: Chars Scrolled (Limit): $charsScrolled") - - runRecap(chapterIndex, charsScrolled.toInt()) - } else { - bannerMessage = BannerMessage("Wait for book to load fully.", isError = true) - } - } - } - }, onToggleTts = { if (isTtsSessionActive) { Timber.d("TTS button clicked: Stopping TTS") @@ -3874,9 +4073,6 @@ fun EpubReaderHost( } } }, - onPlayPauseTts = { - if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() - }, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = bottomPadding) @@ -3922,27 +4118,21 @@ fun EpubReaderHost( .padding(horizontal = 16.dp) ) + val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) { + currentChapterInPaginatedMode ?: currentChapterIndex + } else { + currentChapterIndex + } + EpubReaderAiOverlays( - showSummarizationPopup = showSummarizationPopup, + bookTitle = epubBook.title, + summaryCacheManager = summaryCacheManager, summarizationResult = summarizationResult, isSummarizationLoading = isSummarizationLoading, - onDismissSummarization = { - showSummarizationPopup = false - isSummarizationLoading = false - summarizationResult = null - }, showSummarizationUpsellDialog = showSummarizationUpsellDialog, onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false }, - - showRecapPopup = showRecapPopup, recapResult = recapResult, isRecapLoading = isRecapLoading, - onDismissRecap = { - showRecapPopup = false - isRecapLoading = false - recapResult = null - }, - showAiDefinitionPopup = showAiDefinitionPopup, selectedTextForAi = selectedTextForAi, aiDefinitionResult = aiDefinitionResult, @@ -3962,12 +4152,31 @@ fun EpubReaderHost( isTtsSessionActive = isTtsSessionActive, onOpenExternalDictionary = { text -> if (!selectedDictPackage.isNullOrEmpty()) { - ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) + ExternalDictionaryHelper.launchDictionary( + context, + selectedDictPackage!!, + text + ) } else { - Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + "Select an offline dictionary first.", + Toast.LENGTH_SHORT + ).show() showDictionarySettingsSheet = true } - } + }, + getAuthToken = { viewModel.getAuthToken() }, + credits = credits, + isProUser = isProUser, + currentChapterIndex = effectiveCurrentChapterIndex, + chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}", + showAiHubSheet = showAiHubSheet, + onGenerateSummary = handleGenerateSummary, + onGenerateRecap = handleGenerateRecap, + onDismissAiHub = { showAiHubSheet = false }, + onClearSummary = { summarizationResult = null }, + onClearRecap = { recapResult = null } ) if (isNavigatingToPosition) { @@ -4087,8 +4296,8 @@ fun EpubReaderHost( highlightToNoteCfi = null }, onCopy = { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - val clip = android.content.ClipData.newPlainText("Copied Text", targetHighlight.text) + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", targetHighlight.text) clipboardManager.setPrimaryClip(clip) highlightToNoteCfi = null }, @@ -4176,15 +4385,9 @@ fun EpubReaderHost( onSpeakerChange = { newSpeaker -> ttsController.changeSpeaker(newSpeaker) }, - isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER" - ) - } - - if (showTtsControlsSheet) { - TtsControlsSheet( - onDismiss = { showTtsControlsSheet = false }, - onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, - ttsController = ttsController + isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER", + getAuthToken = { viewModel.getAuthToken() }, + bookTitle = epubBook.title ) } @@ -4227,13 +4430,6 @@ fun EpubReaderHost( ) } - if (showDeviceVoiceSettingsSheet) { - DeviceVoiceSettingsSheet( - isVisible = true, - onDismiss = { showDeviceVoiceSettingsSheet = false } - ) - } - if (showVisualOptionsSheet) { VisualOptionsSheet( systemUiMode = systemUiMode, @@ -4297,6 +4493,26 @@ fun EpubReaderHost( ) } + if (showInsufficientCreditsDialog) { + AlertDialog( + onDismissRequest = { showInsufficientCreditsDialog = false }, + icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) }, + title = { Text("Out of Credits") }, + text = { Text("You don't have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.") }, + confirmButton = { + TextButton(onClick = { + showInsufficientCreditsDialog = false + onNavigateToPro() + }) { Text("Get Pro / Add Credits") } + }, + dismissButton = { + TextButton(onClick = { showInsufficientCreditsDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + if (showPaletteManager) { PaletteManagerDialog( currentPalette = currentHighlightPalette, diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt index 16f6199..b8997b3 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt @@ -114,7 +114,8 @@ fun TtsSessionObserver( onToggleTtsStartOnLoad: (Boolean) -> Unit, userStoppedTts: Boolean, scope: CoroutineScope, - currentTtsMode: TtsMode + currentTtsMode: TtsMode, + getAuthToken: suspend () -> String? ) { val prevTtsState = remember { mutableStateOf(ttsState) } @@ -154,7 +155,8 @@ fun TtsSessionObserver( coverImagePath = coverImagePath, onUpdateTtsChapter = onTtsChapterIndexChange, scope = scope, - ttsMode = currentTtsMode + ttsMode = currentTtsMode, + getAuthToken = getAuthToken ) } } else if (wasPlaying && !isPlaying && !sessionFinished) { @@ -262,7 +264,8 @@ private fun handlePaginatedAutoAdvance( coverImagePath: String?, onUpdateTtsChapter: (Int?) -> Unit, scope: CoroutineScope, - ttsMode: TtsMode + ttsMode: TtsMode, + getAuthToken: suspend () -> String? ) { if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { Timber.d("Paginated: Searching for next TTS content...") @@ -293,12 +296,16 @@ private fun handlePaginatedAutoAdvance( val chapterTitle = chapters.getOrNull(chapterToTry)?.title val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } + val token = getAuthToken() + ttsController.start( chunks = nextChapterChunks, bookTitle = epubBookTitle, chapterTitle = chapterTitle, coverImageUri = coverUriString, - ttsMode = ttsMode + ttsMode = ttsMode, + playbackSource = "READER", + authToken = token ) foundContent = true break diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt index c00412e..1c31026 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt @@ -257,16 +257,11 @@ class BookPaginator( private fun getAllTextBlocks(blocks: List): List { return blocks.flatMap { block -> when (block) { - is WrappingContentBlock -> { - Timber.d("PAGINATOR: Found WrappingContentBlock with ${block.paragraphsToWrap.size} paragraphs.") - getAllTextBlocks(block.paragraphsToWrap) - } + is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap) is FlexContainerBlock -> getAllTextBlocks(block.children) + is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) } is TextContentBlock -> listOf(block) - else -> { - Timber.d("PAGINATOR: Skipping non-text block of type ${block::class.simpleName}") - emptyList() - } + else -> emptyList() } } } @@ -833,7 +828,17 @@ class BookPaginator( override fun getPlainTextForChapter(chapterIndex: Int): String? { val chapter = chapters.getOrNull(chapterIndex) ?: return null - return Jsoup.parse(chapter.htmlContent).body().text() + Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}") + val htmlToParse = chapter.htmlContent.ifEmpty { + try { + val file = java.io.File(extractionBasePath, chapter.htmlFilePath) + if (file.exists()) file.readText() else "" + } catch (_: Exception) { + "" + } + } + if (htmlToParse.isBlank()) return null + return Jsoup.parse(htmlToParse).body().text() } private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int { @@ -1075,11 +1080,13 @@ class BookPaginator( suspend fun findPageForLocator(locator: Locator): Int? { val targetChapterIndex = locator.chapterIndex - Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}") + Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator") val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0 + Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}") + if (chapterPages.isNullOrEmpty()) { Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.") return null @@ -1088,57 +1095,87 @@ class BookPaginator( var fallbackPageInChapter = -1 for ((pageIndex, page) in chapterPages.withIndex()) { - for (block in page.content) { - if (block.blockIndex == locator.blockIndex) { - Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex") - + val allTextBlocks = getAllTextBlocks(page.content) + if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) { + Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})") + } + for (textBlock in allTextBlocks) { + if (textBlock.blockIndex == locator.blockIndex) { if (fallbackPageInChapter == -1) { fallbackPageInChapter = pageIndex } + val startOffsetOnPage = textBlock.startCharOffsetInSource + val endOffsetOnPage = startOffsetOnPage + textBlock.content.length - val textBlock = block as? TextContentBlock - if (textBlock != null) { - val startOffsetOnPage = textBlock.startCharOffsetInSource - val endOffsetOnPage = startOffsetOnPage + textBlock.content.length + Timber.tag("POS_DIAG").d(" -> Block Match: page=$pageIndex, targetOffset=${locator.charOffset}, blockRange=[$startOffsetOnPage, $endOffsetOnPage]") - val isInside = locator.charOffset in startOffsetOnPage.. Unit) { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt index 776696a..3b50a40 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/ContentStyler.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp @@ -250,7 +252,17 @@ class ContentStyler( ) } - return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle) + val newTextDecorationColor = if (style.textDecorationColor.isSpecified) { + CssParser.adaptColorForTheme(style.textDecorationColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + } else { + style.textDecorationColor + } + + return style.copy( + spanStyle = newSpanStyle, + blockStyle = newBlockStyle, + textDecorationColor = newTextDecorationColor + ) } private fun embedImagesInSvg(svgContent: String): String { @@ -402,12 +414,44 @@ class ContentStyler( else -> null } - val finalSpanStyle = themedSpanStyle.spanStyle.copy( + var finalSpanStyle = themedSpanStyle.spanStyle.copy( fontFamily = effectiveSpanFontFamily, baselineShift = baselineShift ) + + val hasCustomDeco = themedSpanStyle.textDecorationStyle != null || + themedSpanStyle.textDecorationColor.isSpecified || + themedSpanStyle.textUnderlineOffset.isSpecified + + val combinedDeco = finalSpanStyle.textDecoration ?: TextDecoration.None + + if (hasCustomDeco && combinedDeco.contains(TextDecoration.Underline)) { + val decos = mutableListOf() + if (combinedDeco.contains(TextDecoration.LineThrough)) decos.add(TextDecoration.LineThrough) + finalSpanStyle = finalSpanStyle.copy( + textDecoration = if (decos.isNotEmpty()) TextDecoration.combine(decos) else TextDecoration.None + ) + + val styleStr = themedSpanStyle.textDecorationStyle ?: "solid" + val colorStr = if (themedSpanStyle.textDecorationColor.isSpecified) themedSpanStyle.textDecorationColor.value.toString() else "Unspecified" + val offsetStr = if (themedSpanStyle.textUnderlineOffset.isSpecified) themedSpanStyle.textUnderlineOffset.value.toString() else "0" + + val annotationData = "$styleStr|$colorStr|$offsetStr" + addStringAnnotation("CustomUnderline", annotationData, span.start, span.end) + } + addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end) + val ws = themedSpanStyle.wordSpacing + if (ws.isSpecified && ws.value != 0f) { + val textToStyle = block.text.substring(span.start, span.end) + for (i in textToStyle.indices) { + if (textToStyle[i] == ' ') { + addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1) + } + } + } + if (span.linkHref != null) { addStringAnnotation("URL", span.linkHref, span.start, span.end) } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt index f782ab6..625d9cd 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.isSpecified import timber.log.Timber import java.io.File import java.util.regex.Pattern @@ -427,6 +427,11 @@ object CssParser { var marginBottomStr: String? = null var marginLeftStr: String? = null + var wordSpacing: TextUnit = TextUnit.Unspecified + var textDecorationStyle: String? = null + var textDecorationColor: Color = Color.Unspecified + var textUnderlineOffset: Dp = Dp.Unspecified + var borderTopWidth: Dp? = null var borderRightWidth: Dp? = null var borderBottomWidth: Dp? = null @@ -566,14 +571,42 @@ object CssParser { } } "text-decoration" -> { - spanStyle = spanStyle.copy( - textDecoration = when(value) { - "underline" -> TextDecoration.Underline - "line-through" -> TextDecoration.LineThrough - "none" -> TextDecoration.None - else -> spanStyle.textDecoration - } - ) + val parts = value.split(" ") + val decos = mutableListOf() + + if (parts.contains("underline")) decos.add(TextDecoration.Underline) + if (parts.contains("line-through")) decos.add(TextDecoration.LineThrough) + + if (parts.contains("none")) { + spanStyle = spanStyle.copy(textDecoration = TextDecoration.None) + } else if (decos.isNotEmpty()) { + spanStyle = spanStyle.copy(textDecoration = TextDecoration.combine(decos)) + } + + val styles = listOf("solid", "double", "dotted", "dashed", "wavy") + parts.firstOrNull { it in styles }?.let { textDecorationStyle = it } + parts.firstNotNullOfOrNull { parseColor(it) }?.let { color -> + textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + } + } + "word-spacing" -> { + val trimmedValue = value.trim() + wordSpacing = if (trimmedValue.lowercase() == "normal") { + TextUnit.Unspecified + } else { + parseCssDimensionToTextUnit(value, containerWidthPx, density) + } + } + "text-decoration-style" -> { + textDecorationStyle = value + } + "text-decoration-color" -> { + parseColor(value)?.let { + textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor) + } + } + "text-underline-offset" -> { + textUnderlineOffset = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) } "letter-spacing" -> { val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density) @@ -623,9 +656,9 @@ object CssParser { "padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)) "padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)) - "width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) - "max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) - "height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) + "width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) + "height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx) "background-color" -> { val originalColor = parseColor(value) ?: Color.Unspecified @@ -872,7 +905,10 @@ object CssParser { borderCollapse = borderCollapse, borderSpacing = borderSpacing ) - return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis) + return CssStyle( + spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis, + wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset + ) } private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List { @@ -939,7 +975,46 @@ object CssParser { return Triple(w, s, c) } - // ADD the parseCssSizeToDp function here at the bottom of the object or file + internal fun parseCssDimension( + size: String, + baseFontSizeSp: Float, + density: Float, + containerWidthPx: Int + ): Dp { + val trimmed = size.trim().lowercase() + if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) { + return Dp.Unspecified + } + if (trimmed == "0" || trimmed == "0px") return 0.dp + + return when { + trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified + trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: Dp.Unspecified + trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified + trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified + trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: Dp.Unspecified + trimmed.endsWith("%") -> { + val percent = trimmed.removeSuffix("%").toFloatOrNull() + if (percent != null) { + ((percent / 100f) * containerWidthPx / density).dp + } else { + Dp.Unspecified + } + } + trimmed.endsWith("vw") -> { + val percent = trimmed.removeSuffix("vw").toFloatOrNull() + if (percent != null) { + ((percent / 100f) * containerWidthPx / density).dp + } else { + Dp.Unspecified + } + } + trimmed.endsWith("vh") -> Dp.Unspecified + trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp + else -> Dp.Unspecified + } + } + internal fun parseCssSizeToDp( size: String, baseFontSizeSp: Float, @@ -947,45 +1022,9 @@ object CssParser { containerWidthPx: Int ): Dp { val trimmed = size.trim().lowercase() - // Handle keywords BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it } - - if (trimmed == "0" || trimmed == "0px") return 0.dp - - return when { - trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: 0.dp - trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: 0.dp - trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp - trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp - trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: 0.dp // 1pt ≈ 1.33px - trimmed.endsWith("%") -> { - val percent = trimmed.removeSuffix("%").toFloatOrNull() - if (percent != null) { - ((percent / 100f) * containerWidthPx / density).dp - } else { - 0.dp - } - } - trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp - else -> 0.dp - } - } - - internal fun parseCssDimensionToTextUnit( - dimension: String?, - containerWidthPx: Int, - density: Float - ): TextUnit { - if (dimension.isNullOrBlank()) return TextUnit.Unspecified - val trimmed = dimension.trim().lowercase() - return when { - trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.sp ?: TextUnit.Unspecified - trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified - trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified - trimmed.endsWith("%") -> trimmed.removeSuffix("%").toFloatOrNull()?.let { (it / 100f).em } ?: TextUnit.Unspecified - trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).sp } ?: TextUnit.Unspecified - else -> TextUnit.Unspecified - } + val dim = parseCssDimension(size, baseFontSizeSp, density, containerWidthPx) + return if (dim.isSpecified) dim else 0.dp } internal fun parseColor(colorString: String): Color? { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt b/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt index 38af4e1..50678ab 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt @@ -23,15 +23,18 @@ import android.graphics.BitmapFactory import android.os.Build import timber.log.Timber import androidx.annotation.RequiresApi +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.sp import org.jsoup.Jsoup import org.jsoup.nodes.Element import org.jsoup.nodes.Node @@ -131,7 +134,7 @@ private class SemanticHtmlParser( baseFontSizeSp = textStyle.fontSize.value, density = density.density, constraints = constraints, - isDarkTheme = false // Semantic parsing is always theme-agnostic + isDarkTheme = false ) if (inlineParseResult.fontFaces.isNotEmpty()) { @@ -144,9 +147,7 @@ private class SemanticHtmlParser( } val body = document.body() - return body.children().flatMap { childElement -> - parseNodeToSemanticBlocks(childElement, getElementStyle(body)) - } + return parseContainer(body, getElementStyle(body)) } private fun parseNodeToSemanticBlocks( @@ -267,9 +268,15 @@ private class SemanticHtmlParser( elementStyle.blockStyle.borderBottomLeftRadius > 0.dp if (hasBoxStyles) { - val children = element.children().flatMap { child -> - parseNodeToSemanticBlocks(child, elementStyle) - } + val childStyle = elementStyle.copy( + blockStyle = elementStyle.blockStyle.copy( + backgroundColor = Color.Unspecified, + borderTop = null, borderRight = null, borderBottom = null, borderLeft = null, + padding = BoxBorders(), + margin = BoxBorders() + ) + ) + val children = parseContainer(element, childStyle) listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++)) } else { parseContainer(element, elementStyle) @@ -280,16 +287,57 @@ private class SemanticHtmlParser( "math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle) "img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList() "h1", "h2", "h3", "h4", "h5", "h6" -> { - val (text, spans) = buildSemanticTextAndSpans(element, elementStyle) - if (text.isNotBlank()) { + val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty() + if (hasNonTextChildren) { val level = tagName.substring(1).toIntOrNull() ?: 1 - listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++)) - } else emptyList() + val fontSizeMultiplier = when (level) { + 1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f + } + val headerStyle = elementStyle.copy( + spanStyle = elementStyle.spanStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp + ) + ) + + val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified || + headerStyle.blockStyle.borderTop != null || + headerStyle.blockStyle.borderRight != null || + headerStyle.blockStyle.borderBottom != null || + headerStyle.blockStyle.borderLeft != null || + headerStyle.blockStyle.padding != BoxBorders() || + headerStyle.blockStyle.borderTopLeftRadius > 0.dp || + headerStyle.blockStyle.borderTopRightRadius > 0.dp || + headerStyle.blockStyle.borderBottomRightRadius > 0.dp || + headerStyle.blockStyle.borderBottomLeftRadius > 0.dp + + if (hasBoxStyles) { + val childStyle = headerStyle.copy( + blockStyle = headerStyle.blockStyle.copy( + backgroundColor = Color.Unspecified, + borderTop = null, borderRight = null, borderBottom = null, borderLeft = null, + padding = BoxBorders(), + margin = BoxBorders() + ) + ) + val children = parseContainer(element, childStyle) + listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++)) + } else { + parseContainer(element, headerStyle) + } + } else { + val (text, spans) = buildSemanticTextAndSpans(element, elementStyle) + if (text.isNotBlank()) { + val level = tagName.substring(1).toIntOrNull() ?: 1 + listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++)) + } else emptyList() + } } "hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++)) "ul", "ol" -> parseListElementToSemantic(element, elementStyle) else -> { - if (element.isBlock) { + val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty() + if (element.isBlock || hasBlockDescendant) { parseContainer(element, elementStyle) } else { val (text, spans) = buildSemanticTextAndSpans(element, elementStyle) @@ -316,13 +364,30 @@ private class SemanticHtmlParser( if (textNodesBuffer.isEmpty()) return val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style) if (text.isNotBlank()) { - children.add(SemanticParagraph(text, spans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) } + val finalSpans = spans.toMutableList() + if (element.tagName().lowercase() == "a") { + val href = element.attr("href").ifBlank { null } + if (href != null) { + finalSpans.add(SemanticSpan( + start = 0, + end = text.length, + style = style, + linkHref = href, + tag = "a", + elementId = element.id().ifBlank { null } + )) + } + } + children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) + } textNodesBuffer.clear() } element.childNodes().forEach { node -> if (node is Element) { - val isEffectivelyBlock = node.isBlock || node.tagName().lowercase() in listOf("img", "svg", "math-placeholder", "hr") + val tagName = node.tagName().lowercase() + val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") || + (!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()) if (isEffectivelyBlock) { flushTextBuffer() @@ -462,8 +527,12 @@ private class SemanticHtmlParser( try { BitmapFactory.Options().apply { inJustDecodeBounds = true } .also { BitmapFactory.decodeFile(imageFile.absolutePath, it) } - .let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) } - } catch (_: Exception) { + .let { + Timber.tag("IMAGE_DIAG").d("Parsed file bounds: ${it.outWidth}x${it.outHeight} for ${imageFile.name}") + Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) + } + } catch (e: Exception) { + Timber.tag("IMAGE_DIAG").e(e, "Failed to parse image bounds for ${imageFile.name}") Pair(null, null) } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index 024e1be..f2b5cb6 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -142,6 +142,7 @@ class LocatorConverter( } suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) { + Timber.tag("POS_DIAG").d("getLocatorFromCfi: Input CFI='$cfi' for chapterIndex=$chapterIndex") val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex) var allBlocks: List? = null @@ -167,14 +168,15 @@ class LocatorConverter( val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) if (bestMatch != null) { - Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}") - Locator( + val locator = Locator( chapterIndex = chapterIndex, blockIndex = bestMatch.blockIndex, charOffset = charOffset ) + Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator") + locator } else { - Timber.tag("PosSaveDiag").e("No semantic block match found for baseCfiPath $baseCfiPath inside ${allBlocks.size} parsed blocks") + Timber.tag("POS_DIAG").e("getLocatorFromCfi: Failed to find semantic block match for CFI path $baseCfiPath") null } } @@ -201,15 +203,20 @@ class LocatorConverter( .filter { it.cfi != null } .map { block -> val blockCfi = block.cfi!! + + val isPrefix = inputCfi == blockCfi || inputCfi.startsWith("$blockCfi/") + val prefixScore = if (isPrefix) blockCfi.length else 0 + var i = inputCfi.length - 1 var j = blockCfi.length - 1 - var length = 0 + var suffixScore = 0 while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) { - length++ + suffixScore++ i-- j-- } - Pair(block, length) + + Pair(block, maxOf(prefixScore, suffixScore)) } .maxByOrNull { it.second } ?.first @@ -218,6 +225,7 @@ class LocatorConverter( } suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) { + Timber.tag("POS_DIAG").d("getCfiFromLocator: Input $locator") val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) var blocks: List? = null @@ -236,13 +244,15 @@ class LocatorConverter( } val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex) - foundBlock?.cfi?.let { cfi -> + val resultCfi = foundBlock?.cfi?.let { cfi -> if (locator.charOffset > 0) { "$cfi:${locator.charOffset}" } else { cfi } } + Timber.tag("POS_DIAG").d("getCfiFromLocator: Resulting CFI='$resultCfi'") + resultCfi } private fun findBlockByBlockIndex(blocks: List, targetBlockIndex: Int): SemanticBlock? { 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 26cd25e..fd947f1 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -1,4 +1,6 @@ // PaginatedReader.kt +@file:Suppress("VariableNeverRead") + package com.aryan.reader.paginatedreader import android.annotation.SuppressLint @@ -9,6 +11,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.widget.Toast +import androidx.compose.ui.unit.isSpecified import androidx.annotation.RequiresApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -24,7 +27,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -49,6 +51,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -73,6 +76,8 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath @@ -614,6 +619,18 @@ fun PaginatedReaderScreen( ) } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}") + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling -> + Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling") + } + } + LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) { Timber.d("Formatting changed. Waiting for debounce.") @@ -755,7 +772,7 @@ fun PaginatedReaderScreen( LaunchedEffect(paginator) { if (anchorLocatorForReconfig != null) { - Timber.tag("ThemeReconfig").d("Restoration Effect Triggered for Locator: $anchorLocatorForReconfig") + Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig") snapshotFlow { paginator.isLoading }.filter { !it }.first() @@ -763,19 +780,15 @@ fun PaginatedReaderScreen( if (targetLocator != null) { val page = paginator.findPageForLocator(targetLocator) - Timber.tag("ThemeReconfig").d(""" - Restoration Progress: - - Target Locator: $targetLocator - - Paginator found Page: $page - - Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]} - """.trimIndent()) + Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page") if (page != null) { pagerState.scrollToPage(page) + Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page") } else { val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex] if (startPage != null) { - Timber.tag("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage") + Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage") pagerState.scrollToPage(startPage) } } @@ -831,7 +844,15 @@ fun PaginatedReaderScreen( textStyle = textStyle, horizontalPadding = horizontalPadding, verticalPadding = verticalPadding, - onGetPage = { pageIndex -> paginator.getPageContent(pageIndex) }, + onGetPage = { pageIndex -> + val startTime = System.currentTimeMillis() + val result = paginator.getPageContent(pageIndex) + val duration = System.currentTimeMillis() - startTime + if (duration > 16) { + Timber.tag("PageTurnDiag").w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}") + } + result + }, onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) }, onGetChapterInfo = { pageIndex -> paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex -> @@ -1186,8 +1207,206 @@ private fun TextWithEmphasis( var layoutCoordinates by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var pressedHighlightCfi by remember { mutableStateOf(null) } + val density = LocalDensity.current + + data class EmphasisMarkInfo(val center: Offset, val radius: Float, val color: Color) + data class UnderlineDrawInfo(val path: Path?, val effect: PathEffect?, val minX: Float, val maxX: Float, val y: Float, val decoStyle: String, val decoColor: Color) + + // --- CACHING DECORATIONS FOR PERFORMANCE --- + val cachedHighlights = remember(block, userHighlights, textLayoutResult, pressedHighlightCfi) { + val startTime = System.currentTimeMillis() + val paths = mutableListOf>() + val layout = textLayoutResult + if (layout != null && block.cfi != null && userHighlights.isNotEmpty()) { + userHighlights.forEach { highlight -> + val range = getHighlightOffsetsInBlock(block, highlight) + if (range != null) { + try { + val path = layout.getPathForRange(range.first, range.last + 1) + paths.add(path to highlight.color.color.copy(alpha = 0.4f)) + if (highlight.cfi == pressedHighlightCfi) { + paths.add(path to Color.Black.copy(alpha = 0.1f)) + } + } catch (e: Exception) { + Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds") + } + } + } + } + val duration = System.currentTimeMillis() - startTime + if (duration > 5) { + Timber.tag("DecorationsDiag").w("Calculated highlight paths for block ${block.blockIndex} in ${duration}ms") + } + paths + } + + val cachedEmphasisMarks = remember(textLayoutResult, text, style.color, density) { + val startTime = System.currentTimeMillis() + val marks = mutableListOf() + val layout = textLayoutResult + if (layout != null) { + val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length) + if (emphasisAnnotations.isNotEmpty()) { + with(density) { // Provides the scope for .toPx() + emphasisAnnotations.forEach { annotation -> + val emphasis = parseEmphasisAnnotation(annotation.item, style.color) + val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color + val markSize = layout.layoutInput.style.fontSize.toPx() * 0.3f + for (offset in annotation.start until annotation.end) { + if (offset >= text.text.length || text.text[offset].isWhitespace()) continue + try { + val boundingBox = layout.getBoundingBox(offset) + val center = Offset( + boundingBox.center.x, + if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f + else boundingBox.top - markSize * 0.1f + ) + marks.add(EmphasisMarkInfo(center, markSize / 2, markColor)) + } catch (e: Exception) { + Timber.tag("DecorationsDiag").e(e, "Emphasis mark out of bounds") + } + } + } + } + } + } + val duration = System.currentTimeMillis() - startTime + if (duration > 5) { + Timber.tag("DecorationsDiag").w("Calculated emphasis marks for block ${block.blockIndex} in ${duration}ms") + } + marks + } + + val cachedUnderlines = remember(textLayoutResult, text, style.color, density) { + val startTime = System.currentTimeMillis() + val lines = mutableListOf() + val layout = textLayoutResult + if (layout != null) { + val customUnderlines = text.getStringAnnotations("CustomUnderline", 0, text.length) + if (customUnderlines.isNotEmpty()) { + val maxIdx = maxOf(0, text.length - 1) + val groupedUnderlines = customUnderlines.groupBy { it.item } + val mergedUnderlines = mutableListOf>() + + groupedUnderlines.forEach { (item, annotations) -> + val sorted = annotations.sortedBy { it.start } + var currentStart = -1 + var currentEnd = -1 + + for (ann in sorted) { + if (currentStart == -1) { + currentStart = ann.start + currentEnd = ann.end + } else if (ann.start <= currentEnd) { + currentEnd = maxOf(currentEnd, ann.end) + } else { + mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd)) + currentStart = ann.start + currentEnd = ann.end + } + } + if (currentStart != -1) { + mergedUnderlines.add(AnnotatedString.Range(item, currentStart, currentEnd)) + } + } + + with(density) { + mergedUnderlines.forEach { annotation -> + val parts = annotation.item.split('|') + val decoStyle = parts.getOrNull(0) ?: "solid" + val colorStr = parts.getOrNull(1) ?: "Unspecified" + val decoColor = if (colorStr != "Unspecified") Color(colorStr.toULong()) else style.color + + val safeStart = annotation.start.coerceIn(0, text.length) + val safeEnd = annotation.end.coerceIn(0, text.length) + if (safeStart < safeEnd) { + val startLine = layout.getLineForOffset(safeStart.coerceIn(0, maxIdx)) + val endLine = layout.getLineForOffset((safeEnd - 1).coerceIn(0, maxIdx)) + + for (line in startLine..endLine) { + val lineStart = layout.getLineStart(line) + val lineEnd = layout.getLineEnd(line, visibleEnd = true) + + val intersectionStart = maxOf(safeStart, lineStart) + val intersectionEnd = minOf(safeEnd, lineEnd) + + var actualStart = intersectionStart + while (actualStart < intersectionEnd && text[actualStart].isWhitespace()) { + actualStart++ + } + + var actualEnd = intersectionEnd + while (actualEnd > actualStart && text[actualEnd - 1].isWhitespace()) { + actualEnd-- + } + + if (actualStart < actualEnd) { + var minX = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + for (i in actualStart until actualEnd) { + try { + val box = layout.getBoundingBox(i) + minX = minOf(minX, box.left, box.right) + maxX = maxOf(maxX, box.left, box.right) + } catch (e: Exception) { + Timber.tag("DecorationsDiag").e(e, "Underline box out of bounds") + } + } + + if (minX < maxX && !minX.isInfinite() && !maxX.isInfinite()) { + val baseline = layout.getLineBaseline(line) + val defaultOffset = layout.layoutInput.style.fontSize.toPx() * 0.1f + val requestedOffset = parts.getOrNull(2)?.toFloatOrNull()?.dp?.toPx() + val y = baseline + (requestedOffset ?: defaultOffset) + + var underlinePath: Path? = null + var effect: PathEffect? = null + + when (decoStyle) { + "wavy" -> { + underlinePath = Path() + underlinePath.moveTo(minX, y) + val waveLength = 4.dp.toPx() + val amplitude = 1.dp.toPx() + var currentX = minX + var isUp = true + + while (currentX < maxX) { + val nextX = minOf(currentX + waveLength / 2f, maxX) + val midX = currentX + (nextX - currentX) / 2f + val cpY = if (isUp) y - amplitude else y + amplitude + underlinePath.quadraticTo(midX, cpY, nextX, y) + currentX = nextX + isUp = !isUp + } + } + "dashed" -> { + effect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx())) + } + "dotted" -> { + effect = PathEffect.dashPathEffect(floatArrayOf(1f, 4.dp.toPx())) + } + } + + lines.add(UnderlineDrawInfo(underlinePath, effect, minX, maxX, y, decoStyle, decoColor)) + } + } + } + } + } + } + } + } + val duration = System.currentTimeMillis() - startTime + if (duration > 5) { + Timber.tag("DecorationsDiag").w("Calculated custom underlines for block ${block.blockIndex} in ${duration}ms") + } + lines + } val customDrawer = Modifier.drawBehind { + val drawStartTime = System.currentTimeMillis() + textLayoutResult?.let { layoutResult -> if (activeSelection != null) { // ADD absolute offset helper: @@ -1219,48 +1438,60 @@ private fun TextWithEmphasis( val path = layoutResult.getPathForRange(sOffset, eOffset) drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f)) } catch (e: Exception) { - Timber.e(e, "Highlight path out of bounds") + Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds") } } } } - if (block.cfi != null && userHighlights.isNotEmpty()) { - userHighlights.forEach { highlight -> - val range = getHighlightOffsetsInBlock(block, highlight) - if (range != null) { - try { - val path = layoutResult.getPathForRange(range.first, range.last + 1) - drawPath(path, highlight.color.color.copy(alpha = 0.4f), blendMode = BlendMode.SrcOver) - if (highlight.cfi == pressedHighlightCfi) { - drawPath(path, Color.Black.copy(alpha = 0.1f), blendMode = BlendMode.SrcOver) - } - } catch (_: Exception) { } - } - } + cachedHighlights.forEach { (path, color) -> + drawPath(path, color, blendMode = BlendMode.SrcOver) } - val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length) - if (emphasisAnnotations.isNotEmpty()) { - emphasisAnnotations.forEach { annotation -> - val emphasis = parseEmphasisAnnotation(annotation.item, style.color) - val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color - val markSize = layoutResult.layoutInput.style.fontSize.toPx() * 0.3f - for (offset in annotation.start until annotation.end) { - if (offset >= text.text.length || text.text[offset].isWhitespace()) continue - try { - val boundingBox = layoutResult.getBoundingBox(offset) - val center = Offset( - boundingBox.center.x, - if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f - else boundingBox.top - markSize * 0.1f + cachedEmphasisMarks.forEach { mark -> + drawCircle(mark.color, mark.radius, mark.center, style = Stroke(1f)) + } + + cachedUnderlines.forEach { line -> + when (line.decoStyle) { + "wavy" -> { + line.path?.let { p -> + drawPath(p, color = line.decoColor, style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + } + "dashed", "dotted" -> { + drawLine( + color = line.decoColor, + start = Offset(line.minX, line.y), + end = Offset(line.maxX, line.y), + strokeWidth = if (line.decoStyle == "dotted") 2.dp.toPx() else 1.dp.toPx(), + cap = if (line.decoStyle == "dotted") StrokeCap.Round else StrokeCap.Butt, + pathEffect = line.effect + ) + } + else -> { // Solid or Double + drawLine( + color = line.decoColor, + start = Offset(line.minX, line.y), + end = Offset(line.maxX, line.y), + strokeWidth = 1.dp.toPx() + ) + if (line.decoStyle == "double") { + drawLine( + color = line.decoColor, + start = Offset(line.minX, line.y + 2.dp.toPx()), + end = Offset(line.maxX, line.y + 2.dp.toPx()), + strokeWidth = 1.dp.toPx() ) - drawCircle(markColor, markSize / 2, center, style = Stroke(1f)) - } catch (_: Exception) { } + } } } } } + val drawDuration = System.currentTimeMillis() - drawStartTime + if (drawDuration > 5) { + Timber.tag("DecorationsDiag").w("Modifier.drawBehind took ${drawDuration}ms for block ${block.blockIndex}") + } } fun getHighlightAt(offset: Offset, layout: TextLayoutResult): Pair? { @@ -1568,6 +1799,7 @@ internal fun PaginatedReaderContent( val down = event.changes.firstOrNull { it.pressed } if (down != null) { pageTurnTouchY = down.position.y + Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}") } } } @@ -1592,10 +1824,21 @@ internal fun PaginatedReaderContent( var currentChapterPath by remember { mutableStateOf(null) } LaunchedEffect(pageIndex, uiState.generation) { + val fetchStartTime = System.currentTimeMillis() + Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch") + pageContent = onGetPage(pageIndex) + + val fetchDuration = System.currentTimeMillis() - fetchStartTime + Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms") + onGetChapterPath(pageIndex)?.let { currentChapterPath = it } } + SideEffect { + Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area") + } + val textBlocksOnPage = pageContent?.content?.extractTextBlocks() ?.filter { it.cfi != null } ?: emptyList() @@ -2263,10 +2506,6 @@ internal fun PaginatedReaderContent( } is FlexContainerBlock -> { - // Background, border, and padding are - // already applied by the outer Box wrapper. - // Only apply padding + width here. - val containerModifier = paddingModifier if (block.style.flexDirection == "row") { val horizontalArrangement = @@ -2284,7 +2523,7 @@ internal fun PaginatedReaderContent( else -> Alignment.Top } Row( - modifier = containerModifier.fillMaxWidth(), + modifier = paddingModifier.fillMaxWidth(), horizontalArrangement = horizontalArrangement, verticalAlignment = verticalAlignment ) { @@ -2336,7 +2575,7 @@ internal fun PaginatedReaderContent( else -> Alignment.Start } Column( - modifier = containerModifier.fillMaxWidth(), + modifier = paddingModifier.fillMaxWidth(), verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment ) { @@ -2505,27 +2744,21 @@ internal fun PaginatedReaderContent( is ImageBlock -> { val style = block.style val finalImageModifier = Modifier.then( - if (style.width != Dp.Unspecified) Modifier.width( - style.width - ) + if (style.width.isSpecified && style.width > 0.dp) Modifier.width(style.width) + else Modifier.fillMaxWidth() + ).then( + if (style.maxWidth.isSpecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth) else Modifier ).then( - if (style.maxWidth != Dp.Unspecified) Modifier.widthIn( - max = style.maxWidth - ) - else Modifier - ).then( - if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) { - Modifier.aspectRatio( - block.intrinsicWidth / block.intrinsicHeight, - matchHeightConstraintsFirst = false - ) - } else if (style.height != Dp.Unspecified) { - Modifier.height(style.height) + if (block.expectedHeight > 0) { + Modifier.height(with(density) { block.expectedHeight.toDp() }) } else { Modifier.height(250.dp) } ).then(paddingModifier) + .onGloballyPositioned { coords -> + Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px") + } val colorFilter = if (block.style.filter == "invert(100%)") { @@ -2732,24 +2965,13 @@ internal fun PaginatedReaderContent( } is ImageBlock -> { - val imageModifier = - Modifier.fillMaxWidth() - .then( - if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { - Modifier.aspectRatio( - blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, - matchHeightConstraintsFirst = false - ) - } else if (blockInCell.style.height != Dp.Unspecified) { - Modifier.height( - blockInCell.style.height - ) - } else { - Modifier.height( - 250.dp - ) - } - ) + val imageModifier = Modifier.fillMaxWidth().then( + if (blockInCell.expectedHeight > 0) { + Modifier.height(with(density) { blockInCell.expectedHeight.toDp() }) + } else { + Modifier.height(250.dp) + } + ) AsyncImage( model = Builder( LocalContext.current @@ -3457,18 +3679,16 @@ private fun RenderFlexChildBlock( val style = childBlock.style val imageModifier = Modifier .then( - if (style.width != Dp.Unspecified) Modifier.width(style.width) + if (style.width != Dp.Unspecified && style.width > 0.dp) Modifier.width(style.width) else Modifier ) .then( - if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth) + if (style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth) else Modifier ) .then( - if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) { - Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false) - } else if (style.height != Dp.Unspecified) { - Modifier.height(style.height) + if (childBlock.expectedHeight > 0) { + Modifier.height(with(density) { childBlock.expectedHeight.toDp() }) } else { Modifier.height(250.dp) } @@ -3582,10 +3802,8 @@ private fun RenderFlexChildBlock( ) } else if (blockInCell is ImageBlock) { val imageModifier = Modifier.fillMaxWidth().then( - if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { - Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false) - } else if (blockInCell.style.height != Dp.Unspecified) { - Modifier.height(blockInCell.style.height) + if (blockInCell.expectedHeight > 0) { + Modifier.height(with(density) { blockInCell.expectedHeight.toDp() }) } else { Modifier.height(250.dp) } @@ -3625,6 +3843,11 @@ private fun Modifier.realisticBookPage( isDarkTheme: Boolean, touchY: Float? ): Modifier = composed { + // Log composition frequency + SideEffect { + Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}") + } + val frontPath = remember { Path() } val backPath = remember { Path() } val reflectedScreenPath = remember { Path() } @@ -3633,6 +3856,11 @@ private fun Modifier.realisticBookPage( .graphicsLayer { val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction + // Log layer property updates + if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) { + Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset") + } + if (pageOffset <= 1f && pageOffset > -1f) { translationX = -pageOffset * size.width } @@ -3644,6 +3872,7 @@ private fun Modifier.realisticBookPage( } } .drawWithContent { + val drawStart = System.nanoTime() val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction if (abs(pageOffset) < 0.001f) { @@ -3670,10 +3899,21 @@ private fun Modifier.realisticBookPage( val dy = cornerY - dragY val nLen = kotlin.math.sqrt(dx * dx + dy * dy) + // CRITICAL GEOMETRY LOG + if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens + Timber.tag("PageTurnFixDiag").i( + "Geometry Page $pageIndex: progress=$progress, nLen=$nLen, cornerY=$cornerY, dragX=$dragX, midX=$midX" + ) + } + if (nLen > 0f) { val nx = dx / nLen val ny = dy / nLen + if (nx.isNaN() || ny.isNaN()) { + Timber.tag("PageTurnFixDiag").e("NAN DETECTED in Normal Vectors: nx=$nx, ny=$ny") + } + val huge = w * 3f val vx = -ny @@ -3733,17 +3973,12 @@ private fun Modifier.realisticBookPage( clipRect(0f, 0f, w, h) { clipPath(frontPath) { drawPath(reflectedScreenPath, color = paperColor) - val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) drawPath(reflectedScreenPath, color = flapTint) val innerShadowWidth = shadowWidth * 0.7f val innerShadowBrush = Brush.linearGradient( - colors = listOf( - Color.Black.copy(alpha = 0.25f), - Color.Black.copy(alpha = 0.05f), - Color.Transparent - ), + colors = listOf(Color.Black.copy(alpha = 0.25f), Color.Black.copy(alpha = 0.05f), Color.Transparent), start = Offset(midX, midY), end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth) ) @@ -3769,16 +4004,15 @@ private fun Modifier.realisticBookPage( drawContent() } } - else if (pageOffset > 0f && pageOffset <= 1f) { - drawRect(color = paperColor) - drawContent() - val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f) - drawRect(color = Color.Black.copy(alpha = dimAlpha)) - } else { drawRect(color = paperColor) drawContent() } + + val drawDuration = (System.nanoTime() - drawStart) / 1_000_000.0 + if (drawDuration > 12.0) { // Log slow frames (anything near the 16ms frame budget) + Timber.tag("PageTurnFixDiag").w("Slow Draw on Page $pageIndex: ${drawDuration}ms") + } } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt index 58afcb1..b2142fc 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReaderData.kt @@ -303,7 +303,11 @@ data class CssStyle( @ProtoNumber(9) val content: String? = null, @ProtoNumber(10) val hyphens: String? = null, @ProtoNumber(11) val fontVariantNumeric: String? = null, - @ProtoNumber(12) val textEmphasis: TextEmphasis? = null + @ProtoNumber(12) val textEmphasis: TextEmphasis? = null, + @ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified, + @ProtoNumber(14) val textDecorationStyle: String? = null, + @ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified, + @ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified ) { fun merge(other: CssStyle): CssStyle { return CssStyle( @@ -318,7 +322,11 @@ data class CssStyle( content = other.content ?: this.content, hyphens = other.hyphens ?: this.hyphens, fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric, - textEmphasis = other.textEmphasis ?: this.textEmphasis + textEmphasis = other.textEmphasis ?: this.textEmphasis, + wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing, + textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle, + textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor, + textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset ) } } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt index b0bb99f..a8a766a 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt @@ -742,26 +742,25 @@ private suspend fun measureBlockHeight( val imageIntrinsicWidth = block.intrinsicWidth val imageIntrinsicHeight = block.intrinsicHeight - if (imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0) { - val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth - val styledWidthDp = block.style.width + val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null + val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null - val imageRenderWidthPx = if (styledWidthDp != Dp.Unspecified) { - with(density) { styledWidthDp.toPx() } - } else { - contentMaxWidth + val measuredHeight = when { + styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx + styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> { + val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth + styledWidthPx * aspectRatio } - - val height = (imageRenderWidthPx * aspectRatio).roundToInt() - height - } else { - Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.") - if (block.style.height != Dp.Unspecified) { - with(density) { block.style.height.toPx().roundToInt() } - } else { - with(density) { 250.dp.toPx().roundToInt() } + imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> { + val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth + contentMaxWidth * aspectRatio } + else -> with(density) { 250.dp.toPx() } } + + val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt() + Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") + finalHeight } is SpacerBlock -> { val height = with(density) { block.height.toPx().roundToInt() } diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt index da0433d..2324cff 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheDatabase.kt @@ -198,7 +198,7 @@ abstract class BookCacheDao { ConfigurationCache::class, AnchorIndexEntry::class ], - version = 6, + version = 7, exportSchema = false ) abstract class BookCacheDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt index 69b11ad..4661cac 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/data/BookCacheEntities.kt @@ -25,7 +25,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -const val LATEST_PROCESSING_VERSION = 6 +const val LATEST_PROCESSING_VERSION = 7 @Entity(tableName = "processed_books") data class ProcessedBook( 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 e421dc2..22de7c1 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -439,6 +439,7 @@ internal fun PdfPageComposable( isVisible: Boolean = true, isActivePage: Boolean = true, isStylusOnlyMode: Boolean = false, + isAutoScrollPlaying: Boolean = false, isHighlighterSnapEnabled: Boolean = false, userHighlights: List = emptyList(), onHighlightAdd: (Int, Pair, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, @@ -1073,6 +1074,7 @@ internal fun PdfPageComposable( canvasHeightPx.floatValue, isVerticalScroll, isScrolling, + isAutoScrollPlaying, virtualPage, isActivePage ) { @@ -1109,7 +1111,17 @@ internal fun PdfPageComposable( try { page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) } - snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect -> + snapshotFlow { + val rect = visibleScreenRect() + if (rect == null) null + else { + val qTop = rect.top / (tileSizePx / 2) + val qLeft = rect.left / (tileSizePx / 2) + val qBottom = rect.bottom / (tileSizePx / 2) + val qRight = rect.right / (tileSizePx / 2) + listOf(qTop, qLeft, qBottom, qRight) + } + }.conflate().collectLatest { _ -> delay(150) @@ -1120,6 +1132,8 @@ internal fun PdfPageComposable( return@collectLatest } + val currentVisibleRect = visibleScreenRect() + val pxTl: Float val pxBr: Float val pyTl: Float 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 9e2a53d..c41e8a5 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt @@ -1614,6 +1614,7 @@ internal fun PdfVerticalReader( selectedTool = selectedTool, richTextController = richTextController, isStylusOnlyMode = isStylusOnlyMode, + isAutoScrollPlaying = isAutoScrollPlaying, textBoxes = textBoxes.filter { it.pageIndex == page.index }, selectedTextBoxId = selectedTextBoxId, onTextBoxChange = onTextBoxChange, 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 be15ffa..c77d926 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -32,7 +32,6 @@ import android.content.Context import android.content.pm.PackageManager import androidx.compose.material3.Switch import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Tune import androidx.compose.foundation.rememberScrollState import android.graphics.Bitmap import android.graphics.RectF @@ -269,8 +268,8 @@ import androidx.paging.compose.itemKey import androidx.work.WorkInfo import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionResult +import com.aryan.reader.AiHubBottomSheet import com.aryan.reader.BuildConfig -import com.aryan.reader.DeviceVoiceSettingsSheet import com.aryan.reader.FileType import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.MainViewModel @@ -279,15 +278,14 @@ import com.aryan.reader.ReaderTheme import com.aryan.reader.ReaderThemePanel import com.aryan.reader.SearchResult import com.aryan.reader.SearchTopBar -import com.aryan.reader.SummarizationPopup import com.aryan.reader.SummarizationResult +import com.aryan.reader.SummaryCacheManager import com.aryan.reader.TooltipIconButton import com.aryan.reader.TtsSettingsSheet -import com.aryan.reader.countWords import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.ExternalDictionaryHelper -import com.aryan.reader.epubreader.TtsControlsSheet +import com.aryan.reader.epubreader.TtsOverlayControls import com.aryan.reader.fetchAiDefinition import com.aryan.reader.loadCustomThemes import com.aryan.reader.paginatedreader.TtsChunk @@ -306,7 +304,6 @@ import com.aryan.reader.saveCustomThemes import com.aryan.reader.summarizationUrl import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.TtsPlaybackManager -import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.splitTextIntoChunks import io.legere.pdfiumandroid.api.Bookmark @@ -1099,7 +1096,7 @@ private fun PdfTocTreeItem( @OptIn(UnstableApi::class) @Suppress("unused") private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) prefs.edit { putString(TTS_MODE_KEY, mode.name) } } @@ -1229,7 +1226,9 @@ fun PdfViewerScreen( var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } val documentCache = remember { DocumentCache(3) } + val summaryCacheManager = remember(context) { SummaryCacheManager(context) } val tabStateMap = remember { mutableStateMapOf() } + var showInsufficientCreditsDialog by remember { mutableStateOf(false) } val activeTheme = remember(currentThemeId, customThemes) { PdfBuiltInThemes.find { it.id == currentThemeId } @@ -1298,6 +1297,7 @@ fun PdfViewerScreen( } var currentBookId by remember { mutableStateOf(null) } val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString() + var documentMetadataTitle by remember { mutableStateOf(null) } val view = LocalView.current var isDockDragging by remember { mutableStateOf(false) } var initialScrollDone by remember { mutableStateOf(false) } @@ -1320,14 +1320,24 @@ fun PdfViewerScreen( var isAutoScrollTempPaused by remember { mutableStateOf(false) } val autoScrollResumeJob = remember { mutableStateOf(null) } var isAutoScrollCollapsed by remember { mutableStateOf(false) } + var isTtsCollapsed by remember { mutableStateOf(false) } var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) } - var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) } - var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) } var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } + val ttsController = rememberTtsController() + val ttsState by ttsController.ttsState.collectAsState() + ttsState.currentText + var currentTtsMode by remember { + mutableStateOf( + com.aryan.reader.tts.loadTtsMode(context).let { + if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it + } + ) + } + var showTtsSettingsSheet by remember { mutableStateOf(false) } DisposableEffect(isKeepScreenOn) { view.keepScreenOn = isKeepScreenOn @@ -1342,8 +1352,6 @@ fun PdfViewerScreen( var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) } var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) } - var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) } - fun triggerAutoScrollTempPause(durationMs: Long) { if (!isAutoScrollModeActive || !isAutoScrollPlaying) return autoScrollResumeJob.value?.cancel() @@ -1587,6 +1595,19 @@ fun PdfViewerScreen( } } + LaunchedEffect(ttsState.errorMessage) { + ttsState.errorMessage?.let { message -> + if (message == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + ttsController.stop() + } else { + coroutineScope.launch { + snackbarHostState.showSnackbar(message) + } + } + } + } + val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) } val toolSettings by annotationSettingsRepo.settings.collectAsState() var showToolSettings by rememberSaveable { mutableStateOf(false) } @@ -2648,7 +2669,7 @@ fun PdfViewerScreen( var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) } - var showSummarizationPopup by remember { mutableStateOf(false) } + var showAiHubSheet by remember { mutableStateOf(false) } var summarizationResult by remember { mutableStateOf(null) } var isSummarizationLoading by remember { mutableStateOf(false) } @@ -2659,17 +2680,18 @@ fun PdfViewerScreen( val scrubDebounceJob = remember { mutableStateOf(null) } var startPageThumbnail by remember { mutableStateOf(null) } - val speakerPlayer = - remember(context, coroutineScope) { SpeakerSamplePlayer(context, coroutineScope) } + val speakerPlayer = remember(context, coroutineScope) { + SpeakerSamplePlayer( + context = context, + scope = coroutineScope, + getAuthToken = { viewModel.getAuthToken() } + ) + } var clickedLinkUrl by remember { mutableStateOf(null) } val uriHandler = LocalUriHandler.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current - val ttsController = rememberTtsController() - val ttsState by ttsController.ttsState.collectAsState() - ttsState.currentText - var showRenameBookmarkDialog by remember { mutableStateOf(null) } var isOcrModelDownloading by remember { mutableStateOf(false) } @@ -2723,35 +2745,43 @@ fun PdfViewerScreen( } } - val onDictionaryLookupStable = remember(isProUser, executeWithOcrCheck, useOnlineDictionary, selectedDictPackage) { + val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) { { text: String -> executeWithOcrCheck { val isOss = BuildConfig.FLAVOR == "oss" val effectiveUseOnline = !isOss && useOnlineDictionary if (effectiveUseOnline) { - val wordCount = countWords(text) - if (isProUser || wordCount <= 1) { + val wordCount = com.aryan.reader.countWords(text) + if (wordCount > 1 && !isProUser) { + showDictionaryUpsellDialog = true + } else { selectedTextForAi = text showAiDefinitionPopup = true coroutineScope.launch { + val token = viewModel.getAuthToken() isAiDefinitionLoading = true aiDefinitionResult = null fetchAiDefinition( - text = text, onUpdate = { chunk -> - val currentDefinition = aiDefinitionResult?.definition ?: "" - aiDefinitionResult = AiDefinitionResult( - definition = currentDefinition + chunk - ) - }, onError = { error -> - aiDefinitionResult = AiDefinitionResult(error = error) - }, onFinish = { - isAiDefinitionLoading = false - }, context = context + text = text, + authToken = token, + onUpdate = { chunk -> + val currentDefinition = aiDefinitionResult?.definition ?: "" + aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk) + }, + onError = { error -> + if (error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiDefinitionPopup = false + isAiDefinitionLoading = false + } else { + aiDefinitionResult = AiDefinitionResult(error = error) + } + }, + onFinish = { isAiDefinitionLoading = false }, + context = context ) } - } else { - showDictionaryUpsellDialog = true } } else { if (!selectedDictPackage.isNullOrEmpty()) { @@ -2835,6 +2865,7 @@ fun PdfViewerScreen( } suspend fun summarizeCurrentPage( + authToken: String?, onUpdate: (SummarizationResult) -> Unit, onFinish: () -> Unit ) { val currentPageIndex = currentPage @@ -2892,28 +2923,50 @@ fun PdfViewerScreen( put("content_type", "image") put("data", base64Image) } + if (authToken != null) { + connection.setRequestProperty("Authorization", "Bearer $authToken") + } connection.outputStream.use { os -> os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) } val responseCode = connection.responseCode Timber.d("Summarization API response code: $responseCode") + if (responseCode == 402) { + onUpdate(SummarizationResult(error = "INSUFFICIENT_CREDITS")) + onFinish() + return@withContext + } if (responseCode == HttpURLConnection.HTTP_OK) { val fullText = StringBuilder() var lastResult: SummarizationResult? = null + var currentCost: Double? = null + var currentFreeRemaining: Int? = null + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> var line: String? while (reader.readLine().also { line = it } != null) { try { val jsonResponse = JSONObject(line!!) + + val cost = if (jsonResponse.has("cost_deducted")) jsonResponse.optDouble("cost_deducted", -1.0) else -1.0 + val freeRemaining = jsonResponse.optInt("free_summaries_remaining", -1) + + if (cost > -1.0 || freeRemaining > -1) { + if (cost > -1.0) currentCost = cost + if (freeRemaining > -1) currentFreeRemaining = freeRemaining + lastResult = SummarizationResult(summary = fullText.toString(), cost = currentCost, freeRemaining = currentFreeRemaining) + onUpdate(lastResult) + } + jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let { fullText.append(it) - lastResult = SummarizationResult(summary = fullText.toString()) - @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") onUpdate(lastResult!!) + lastResult = SummarizationResult(summary = fullText.toString(), cost = currentCost, freeRemaining = currentFreeRemaining) + onUpdate(lastResult!!) } jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let { - lastResult = SummarizationResult(error = it) + lastResult = SummarizationResult(error = it, cost = currentCost, freeRemaining = currentFreeRemaining) onUpdate(lastResult) } } catch (e: Exception) { @@ -3032,11 +3085,17 @@ fun PdfViewerScreen( } fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) { + if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && uiState.credits <= 0) { + showInsufficientCreditsDialog = true + return + } + Timber.d("TTS button clicked: Starting TTS for current page/selection") if (pdfDocument == null || totalPages == 0) { return } coroutineScope.launch { + val token = viewModel.getAuthToken() val pageToRead = pageToReadOverride ?: currentPage var rawPageText: String? = null var tempPage: ReaderPage? = null @@ -3108,7 +3167,8 @@ fun PdfViewerScreen( chapterTitle = pageTitle, coverImageUri = null, ttsMode = currentTtsMode, - playbackSource = "READER" + playbackSource = "READER", + authToken = token ) if (isAutoPagingForTts) { @@ -3269,6 +3329,7 @@ fun PdfViewerScreen( isLoadingDocument = true isDocumentReady = false errorMessage = null + documentMetadataTitle = null if (showPasswordDialog) isPasswordError = false @@ -3336,6 +3397,7 @@ fun PdfViewerScreen( } pdfDocument = doc + documentMetadataTitle = (doc as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() } pfdState = currentPfdOpened val pagesCount = doc.getPageCount() @@ -3525,6 +3587,7 @@ fun PdfViewerScreen( } } previousPage = currentPage + summarizationResult = null } LaunchedEffect(pagerState.currentPage) { @@ -3862,7 +3925,7 @@ fun PdfViewerScreen( showBars = true } - showSummarizationPopup -> showSummarizationPopup = false + showAiHubSheet -> showAiHubSheet = false showPermissionRationaleDialog -> showPermissionRationaleDialog = false showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false showAiDefinitionPopup -> showAiDefinitionPopup = false @@ -3991,55 +4054,103 @@ fun PdfViewerScreen( } } - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxHeight() - .padding(end = 12.dp) - ) { - items( - items = visibleItemInfo, - key = { it.second.title + it.first } - ) { item -> - val (originalIndex, entry) = item - - val nextItem = flatTableOfContents.getOrNull(originalIndex + 1) - val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel - val isExpanded = expandedEntryIndices.contains(originalIndex) - val isCurrentChapter = entry == currentTocEntry - - PdfTocTreeItem( - label = entry.title, - nestLevel = entry.nestLevel, - isExpanded = isExpanded, - hasChildren = hasChildren, - isCurrent = isCurrentChapter, - onToggleExpand = { - expandedEntryIndices = if (isExpanded) { - expandedEntryIndices - originalIndex - } else { - expandedEntryIndices + originalIndex - } - }, - onClick = { - coroutineScope.launch { - drawerState.close() - if (displayMode == DisplayMode.PAGINATION) { - pagerState.scrollToPage(entry.pageIndex) - } else { - verticalReaderState.scrollToPage(entry.pageIndex) - } - } + val onScrollToCurrent = { + drawerScope.launch { + val targetEntry = currentTocEntry ?: return@launch + val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry) + if (targetOriginalIndex != -1) { + var currentLevel = targetEntry.nestLevel + val newExpanded = expandedEntryIndices.toMutableSet() + for (i in targetOriginalIndex downTo 0) { + val entry = flatTableOfContents[i] + if (entry.nestLevel < currentLevel) { + newExpanded.add(i) + currentLevel = entry.nestLevel } - ) + } + expandedEntryIndices = newExpanded + + delay(100) + + val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry } + if (visibleIdx != -1) { + listState.animateScrollToItem(visibleIdx) + } + } + } + Unit + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) { + Text("Expand All") + } + TextButton(onClick = { expandedEntryIndices = emptySet() }) { + Text("Collapse All") + } + TextButton(onClick = onScrollToCurrent) { + Text("Locate") } } - VerticalScrollbar( - listState = listState, - modifier = Modifier.align(Alignment.CenterEnd) - ) + HorizontalDivider() + + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxHeight() + .padding(end = 12.dp) + ) { + items( + items = visibleItemInfo, + key = { it.second.title + it.first } + ) { item -> + val (originalIndex, entry) = item + + val nextItem = flatTableOfContents.getOrNull(originalIndex + 1) + val hasChildren = nextItem != null && nextItem.nestLevel > entry.nestLevel + val isExpanded = expandedEntryIndices.contains(originalIndex) + val isCurrentChapter = entry == currentTocEntry + + PdfTocTreeItem( + label = entry.title, + nestLevel = entry.nestLevel, + isExpanded = isExpanded, + hasChildren = hasChildren, + isCurrent = isCurrentChapter, + onToggleExpand = { + expandedEntryIndices = if (isExpanded) { + expandedEntryIndices - originalIndex + } else { + expandedEntryIndices + originalIndex + } + }, + onClick = { + coroutineScope.launch { + drawerState.close() + if (displayMode == DisplayMode.PAGINATION) { + pagerState.scrollToPage(entry.pageIndex) + } else { + verticalReaderState.scrollToPage(entry.pageIndex) + } + } + } + ) + } + } + + VerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } } } } @@ -4723,6 +4834,7 @@ fun PdfViewerScreen( }, richTextController = richTextController, isStylusOnlyMode = isStylusOnlyMode, + isAutoScrollPlaying = isAutoScrollPlaying, isHighlighterSnapEnabled = isHighlighterSnapEnabled, isEditMode = isDrawingActive, textBoxes = textBoxes.filter { it.pageIndex == pageIndex }, @@ -5833,9 +5945,10 @@ fun PdfViewerScreen( if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { DropdownMenuItem( text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, + enabled = !isTtsSessionActive, onClick = { showMoreMenu = false - showDeviceVoiceSettingsSheet = true + showTtsSettingsSheet = true }, leadingIcon = { Icon( @@ -5845,24 +5958,6 @@ fun PdfViewerScreen( ) } ) - - if (BuildConfig.DEBUG) { - DropdownMenuItem( - text = { Text(stringResource(R.string.menu_tts_settings_debug)) }, - onClick = { - showMoreMenu = false - showTtsSettingsSheet = true - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.text_to_speech), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - ) - } - HorizontalDivider() } if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) { DropdownMenuItem(text = { @@ -6429,43 +6524,15 @@ fun PdfViewerScreen( // AI feat if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) { - Box { - var showAiFeaturesMenu by remember { mutableStateOf(false) } - TooltipIconButton( - text = stringResource(R.string.tooltip_ai), - description = stringResource(R.string.tooltip_ai_desc), - onClick = { showAiFeaturesMenu = true } - ) { - Icon( - painter = painterResource(id = R.drawable.ai), - contentDescription = stringResource(R.string.tooltip_ai) - ) - } - DropdownMenu( - expanded = showAiFeaturesMenu, - onDismissRequest = { showAiFeaturesMenu = false }) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.action_summarize_page)) - }, onClick = { - showAiFeaturesMenu = false - if (isProUser) { - showSummarizationPopup = true - coroutineScope.launch { - isAiDefinitionLoading = true - summarizationResult = null - summarizeCurrentPage(onUpdate = { result -> - summarizationResult = result - }, onFinish = { - isAiDefinitionLoading = false - }) - } - } else { - showSummarizationUpsellDialog = true - } - }, enabled = !isSummarizationLoading && pdfDocument != null - ) - } + TooltipIconButton( + text = stringResource(R.string.tooltip_ai), + description = stringResource(R.string.tooltip_ai_desc), + onClick = { showAiHubSheet = true } + ) { + Icon( + painter = painterResource(id = R.drawable.ai), + contentDescription = stringResource(R.string.tooltip_ai) + ) } } @@ -6506,72 +6573,25 @@ fun PdfViewerScreen( // TTS if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) { - Box { - Row(verticalAlignment = Alignment.CenterVertically) { - TooltipIconButton( - text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) - else stringResource(R.string.tooltip_tts_start), - description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) - else stringResource(R.string.tooltip_tts_start_desc), - onClick = { - if (isTtsSessionActive) { - Timber.d("TTS button clicked: Stopping TTS") - ttsController.stop() - } else { - startTtsWithPermissionCheck(null, null) - } - }) { - Icon( - painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) - else painterResource( - id = R.drawable.text_to_speech - ), - contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(R.string.content_desc_start_tts) - ) - } - + TooltipIconButton( + text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) + else stringResource(R.string.tooltip_tts_start), + description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) + else stringResource(R.string.tooltip_tts_start_desc), + onClick = { if (isTtsSessionActive) { - TooltipIconButton( - text = if (ttsState.isPlaying) - stringResource(R.string.tooltip_tts_pause) - else - stringResource(R.string.tooltip_tts_resume), - description = if (ttsState.isPlaying) - stringResource(R.string.tooltip_tts_pause_desc) - else - stringResource(R.string.tooltip_tts_resume_desc), - onClick = { - if (ttsState.isPlaying) { - ttsController.pause() - } else { - ttsController.resume() - } - }, enabled = !ttsState.isLoading - ) { - Icon( - painter = painterResource( - id = if (ttsState.isPlaying) R.drawable.pause - else R.drawable.play - ), contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts) - else stringResource(R.string.content_desc_resume_tts) - ) - } - - // Tune button for BASE mode - if (currentTtsMode == TtsPlaybackManager.TtsMode.BASE) { - TooltipIconButton( - text = stringResource(R.string.tts_voice_adjustments), - description = "Adjust voice speed and pitch", - onClick = { showTtsControlsSheet = true } - ) { - Icon( - imageVector = Icons.Default.Tune, - contentDescription = stringResource(R.string.tts_voice_adjustments) - ) - } - } + Timber.d("TTS button clicked: Stopping TTS") + ttsController.stop() + } else { + startTtsWithPermissionCheck(null, null) } - } + }) { + Icon( + painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) + else painterResource(id = R.drawable.text_to_speech), + contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS", + tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) } } @@ -7190,15 +7210,71 @@ fun PdfViewerScreen( } } - if (showSummarizationPopup) { - SummarizationPopup( - title = "Page Summary", - result = summarizationResult, - isLoading = isSummarizationLoading, - onDismiss = { showSummarizationPopup = false }, - isMainTtsActive = isTtsSessionActive + if (showAiHubSheet) { + val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) { + pagerState.currentPage + } else { + verticalReaderState.currentPage + } + val bookTitle = documentMetadataTitle ?: originalFileName + + AiHubBottomSheet( + bookTitle = bookTitle, + currentChapterIndex = currentPageForDisplay, + chapterTitle = "Page ${currentPageForDisplay + 1}", + summaryCacheManager = summaryCacheManager, + summarizationResult = summarizationResult, + isSummarizationLoading = isSummarizationLoading, + onClearSummary = { summarizationResult = null }, + onGenerateSummary = { force -> + if (!isProUser && uiState.credits <= 0) { + showInsufficientCreditsDialog = true + showAiHubSheet = false + } else { + coroutineScope.launch { + isSummarizationLoading = true + summarizationResult = null + + val cached = if (!force) summaryCacheManager.getSummary(bookTitle, currentPageForDisplay) else null + if (cached != null) { + summarizationResult = SummarizationResult(summary = cached, isCacheHit = true) + isSummarizationLoading = false + return@launch + } + + val token = viewModel.getAuthToken() + summarizeCurrentPage( + authToken = token, + onUpdate = { result -> + if (result.error == "INSUFFICIENT_CREDITS") { + showInsufficientCreditsDialog = true + showAiHubSheet = false + isSummarizationLoading = false + } else { + summarizationResult = result + } + }, onFinish = { + isSummarizationLoading = false + val finalSummary = summarizationResult?.summary + if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) { + summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary) + } + } + ) + } + } + }, + recapResult = null, + isRecapLoading = false, + onGenerateRecap = null, + onDismiss = { showAiHubSheet = false }, + isMainTtsActive = isTtsSessionActive, + getAuthToken = { viewModel.getAuthToken() }, + credits = uiState.credits, + isProUser = isProUser ) } + if (showPermissionRationaleDialog) { AlertDialog( onDismissRequest = { showPermissionRationaleDialog = false }, @@ -7254,6 +7330,26 @@ fun PdfViewerScreen( }) } + if (showInsufficientCreditsDialog) { + AlertDialog( + onDismissRequest = { showInsufficientCreditsDialog = false }, + icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) }, + title = { Text("Out of Credits") }, + text = { Text("You don't have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.") }, + confirmButton = { + TextButton(onClick = { + showInsufficientCreditsDialog = false + onNavigateToPro() + }) { Text("Get Pro / Add Credits") } + }, + dismissButton = { + TextButton(onClick = { showInsufficientCreditsDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } + if (showPasswordDialog) { PasswordDialog( isError = isPasswordError, @@ -7342,7 +7438,8 @@ fun PdfViewerScreen( showDictionarySettingsSheet = true } } - } + }, + getAuthToken = { viewModel.getAuthToken() } ) } if (showDictionaryUpsellDialog) { @@ -7455,6 +7552,7 @@ fun PdfViewerScreen( } if (showTtsSettingsSheet) { + val bookTitle = documentMetadataTitle ?: originalFileName TtsSettingsSheet( isVisible = true, onDismiss = { showTtsSettingsSheet = false }, @@ -7468,15 +7566,9 @@ fun PdfViewerScreen( onSpeakerChange = { newSpeaker -> ttsController.changeSpeaker(newSpeaker) }, - isTtsActive = isTtsSessionActive - ) - } - - if (showTtsControlsSheet) { - TtsControlsSheet( - onDismiss = { showTtsControlsSheet = false }, - onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }, - ttsController = ttsController + isTtsActive = isTtsSessionActive, + getAuthToken = { viewModel.getAuthToken() }, + bookTitle = bookTitle ) } @@ -7508,13 +7600,6 @@ fun PdfViewerScreen( ) } - if (showDeviceVoiceSettingsSheet) { - DeviceVoiceSettingsSheet( - isVisible = true, - onDismiss = { showDeviceVoiceSettingsSheet = false } - ) - } - if (highlightToNoteId != null) { val targetHighlight = userHighlights.find { it.id == highlightToNoteId } if (targetHighlight != null) { @@ -7777,6 +7862,39 @@ fun PdfViewerScreen( label = "AutoScrollPadding" ) + val ttsOverlayPadding by animateDpAsState( + targetValue = if (showBars) (56.dp + 16.dp) else 16.dp, + label = "TtsOverlayPadding" + ) + + val ttsAlignmentBias by animateFloatAsState( + targetValue = if (isTtsCollapsed) 1f else 0f, + label = "TtsAlignAnimation" + ) + + AnimatedVisibility( + visible = isTtsSessionActive && showBars, + enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)), + exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)), + modifier = Modifier + .align(BiasAlignment(ttsAlignmentBias, 1f)) + .padding(bottom = ttsOverlayPadding) + .padding(horizontal = 16.dp) + ) { + TtsOverlayControls( + ttsController = ttsController, + ttsState = ttsState, + currentTtsMode = currentTtsMode, + isCollapsed = isTtsCollapsed, + onCollapseChange = { isTtsCollapsed = it }, + onOpenTtsSettings = { showTtsSettingsSheet = true }, + onClose = { + ttsController.stop() + }, + credits = uiState.credits + ) + } + val isAutoScrollControlsVisible = isAutoScrollModeActive val alignmentBias by animateFloatAsState( diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index b56afee..f0e521a 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -37,6 +37,8 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.aryan.reader.BuildConfig +import com.aryan.reader.epubreader.loadTtsPitch +import com.aryan.reader.epubreader.loadTtsSpeechRate import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors @@ -65,15 +67,12 @@ private fun loadSpeaker(context: Context): String { } @OptIn(UnstableApi::class) -@Suppress("KotlinConstantConditions") fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name) ?: TtsPlaybackManager.TtsMode.BASE.name - val isCloudAllowed = BuildConfig.DEBUG && - BuildConfig.IS_PRO && - BuildConfig.TTS_WORKER_URL.isNotBlank() + val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() return if (isCloudAllowed) { try { @@ -159,7 +158,8 @@ class TtsController(context: Context) : Player.Listener { chapterTitle: String?, coverImageUri: String?, ttsMode: TtsPlaybackManager.TtsMode, - playbackSource: String = "READER" + playbackSource: String = "READER", + authToken: String? = null ) { if (chunks.isEmpty()) { Timber.w("TtsController: start called with empty chunks!") @@ -181,19 +181,12 @@ class TtsController(context: Context) : Player.Listener { putString(KEY_COVER_IMAGE_URI, coverImageUri) putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_PLAYBACK_SOURCE, playbackSource) + putString(KEY_AUTH_TOKEN, authToken) + putFloat("playback_speed", loadTtsSpeechRate(context)) + putFloat("playback_pitch", loadTtsPitch(context)) } + Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}") mediaController?.sendCustomCommand(START_TTS_COMMAND, args) - - val metadataBuilder = androidx.media3.common.MediaMetadata.Builder() - .setArtist(bookTitle) - .setTitle(chapterTitle ?: "Reading Aloud") - coverImageUri?.let { metadataBuilder.setArtworkUri(it.toUri()) } - - val metadata = MediaItem.Builder() - .setMediaId("tts_session") - .setMediaMetadata(metadataBuilder.build()) - .build() - mediaController?.setMediaItem(metadata) } fun pause() { @@ -224,6 +217,10 @@ class TtsController(context: Context) : Player.Listener { @Suppress("unused") fun changeTtsMode(mode: String) { Timber.d("UI sending CHANGE_TTS_MODE command.") + val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) + prefs.edit { putString("tts_mode", mode) } + _ttsState.value = _ttsState.value.copy(ttsMode = mode) + val args = Bundle().apply { putString(KEY_TTS_MODE, mode) } @@ -261,6 +258,7 @@ class TtsController(context: Context) : Player.Listener { val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1 val currentWordSourceCfi = customState.getString("currentWordSourceCfi") val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1) + val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode) val currentState = _ttsState.value _ttsState.value = currentState.copy( @@ -288,11 +286,22 @@ class TtsController(context: Context) : Player.Listener { currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null, currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1, sessionFinished = sessionFinished, - playbackSource = playbackSource + playbackSource = playbackSource, + ttsMode = serviceMode ) } } + + + fun setPlaybackParameters(speed: Float, pitch: Float) { + val args = Bundle().apply { + putFloat("speed", speed) + putFloat("pitch", pitch) + } + mediaController?.sendCustomCommand(SET_PLAYBACK_PARAMS_COMMAND, args) + } + fun release() { pollingJob?.cancel() scope.cancel() diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index 951d142..09e1cd5 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -56,6 +56,7 @@ val FLUSH_PREFETCH_COMMAND = SessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY) val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY) val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY) +val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY) const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS" const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS" @@ -68,20 +69,27 @@ const val KEY_TTS_MODE = "KEY_TTS_MODE" const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS" const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS" const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE" +const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" -private const val PREFETCH_LOOKAHEAD = 2 +private const val PREFETCH_LOOKAHEAD = 3 @UnstableApi class TtsPlaybackManager( private val player: Player, - private val generateAudioChunk: suspend (textChunk: String, speakerId: String, mode: TtsMode) -> TtsAudioData + private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData, + private val onResetContext: () -> Unit ) : MediaSession.Callback, Player.Listener { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var mediaSession: MediaSession? = null - private val prefetchingJobs = mutableMapOf() + private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap() private var wordTrackingJob: Job? = null private var preparationJob: Job? = null + private var prefetchLoopJob: Job? = null + private var lastPrefetchIndex = -1 + private var currentAuthToken: String? = null + private val loadedChunks: MutableSet = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) + private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap() enum class TtsMode { CLOUD, BASE @@ -100,13 +108,14 @@ class TtsPlaybackManager( val currentWordSourceCfi: String? = null, val currentWordStartOffset: Int = -1, val sessionFinished: Boolean = false, - val playbackSource: String? = null + val playbackSource: String? = null, + val ttsMode: String = TtsMode.CLOUD.name ) private val _ttsState = MutableStateFlow(TtsState()) private var textChunks: List = emptyList() - private var audioFiles: MutableMap = mutableMapOf() + private val audioFiles = java.util.concurrent.ConcurrentHashMap() private var currentSpeakerId = DEFAULT_SPEAKER_ID private var bookTitle: String? = null private var chapterTitle: String? = null @@ -141,6 +150,7 @@ class TtsPlaybackManager( .add(CHANGE_TTS_MODE_COMMAND) .add(FLUSH_PREFETCH_COMMAND) .add(SLICE_CURRENT_AND_RELOAD_COMMAND) + .add(SET_PLAYBACK_PARAMS_COMMAND) .build() val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) @@ -192,7 +202,9 @@ class TtsPlaybackManager( chunks.map { TtsChunk(it, "", -1) } } - handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource) + val authToken = args.getString(KEY_AUTH_TOKEN) + Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") + handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource, args) } STOP_TTS_COMMAND -> { Timber.d("Received STOP command.") @@ -209,23 +221,55 @@ class TtsPlaybackManager( } FLUSH_PREFETCH_COMMAND -> { Timber.d("Flushing prefetched TTS chunks for new parameters.") + onResetContext() + lastPrefetchIndex = -1 + prefetchLoopJob?.cancel() prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.clear() - scope.launch(Dispatchers.IO) { - val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex } + + scope.launch(Dispatchers.Main) { + val currentIdx = player.currentMediaItemIndex if (currentIdx == C.INDEX_UNSET) return@launch - val keysToRemove = audioFiles.keys.filter { it > currentIdx } - keysToRemove.forEach { key -> - audioFiles.remove(key)?.delete() + + val keysToRemove = loadedChunks.filter { it > currentIdx } + withContext(Dispatchers.IO) { + keysToRemove.forEach { key -> + loadedChunks.remove(key) + val file = audioFiles.remove(key) + deleteTempFile(file) + val streamId = chunkStreamIds.remove(key) + if (streamId != null) { + StreamRegistry.remove(streamId) + } + } } - withContext(Dispatchers.Main) { - prefetchNextChunkAudio(currentIdx) + + val itemsToRemove = mutableListOf() + for (k in 0 until player.mediaItemCount) { + val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1 + if (id > currentIdx) { + itemsToRemove.add(k) + } } + itemsToRemove.reversed().forEach { + player.removeMediaItem(it) + } + + prefetchNextChunkAudio(currentIdx) } } SLICE_CURRENT_AND_RELOAD_COMMAND -> { handleSliceAndReload() } + SET_PLAYBACK_PARAMS_COMMAND -> { + val speed = args.getFloat("speed", 1f) + val pitch = args.getFloat("pitch", 1f) + if (currentTtsMode == TtsMode.CLOUD) { + scope.launch(Dispatchers.Main) { + player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch) + } + } + } } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } @@ -234,6 +278,11 @@ class TtsPlaybackManager( val currentIdx = player.currentMediaItemIndex if (currentIdx == C.INDEX_UNSET) return + player.pause() + _ttsState.value = _ttsState.value.copy(isLoading = true) + + onResetContext() + val offset = _ttsState.value.currentWordStartOffset val currentChunk = textChunks.getOrNull(currentIdx) ?: return @@ -241,11 +290,14 @@ class TtsPlaybackManager( wordTrackingJob?.cancel() player.stop() player.clearMediaItems() + lastPrefetchIndex = -1 + prefetchLoopJob?.cancel() prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.clear() preparationJob = scope.launch { clearAudioFiles() + loadedChunks.clear() if (offset == -1) { prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false) @@ -275,6 +327,7 @@ class TtsPlaybackManager( private fun handleChangeTtsMode(newMode: TtsMode) { if (currentTtsMode == newMode) return currentTtsMode = newMode + _ttsState.value = _ttsState.value.copy(ttsMode = newMode.name) Timber.d("TTS Mode changed to $newMode (pending next start)") } @@ -285,12 +338,29 @@ class TtsPlaybackManager( chapterTitle: String?, coverImageUri: String?, ttsMode: TtsMode, - playbackSource: String? + playbackSource: String?, + args: Bundle // Added this parameter ) { if (chunks.isEmpty()) { _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") return } + + // --- YOUR SNIPPET START --- + val authToken = args.getString(KEY_AUTH_TOKEN) + val speed = args.getFloat("playback_speed", 1f) + val pitch = args.getFloat("playback_pitch", 1f) + + scope.launch(Dispatchers.Main) { + if (ttsMode == TtsMode.CLOUD) { + player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch) + } else { + player.playbackParameters = androidx.media3.common.PlaybackParameters(1f, 1f) + } + } + + Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") + handleStopTts(clearState = false) textChunks = chunks currentSpeakerId = speakerId @@ -298,13 +368,35 @@ class TtsPlaybackManager( this.bookTitle = bookTitle this.chapterTitle = chapterTitle this.coverImageUri = coverImageUri - _ttsState.value = TtsState(isLoading = true, speakerId = speakerId, playbackSource = playbackSource) + onResetContext() + loadedChunks.clear() + lastPrefetchIndex = -1 + + _ttsState.value = TtsState( + isLoading = true, + speakerId = speakerId, + playbackSource = playbackSource, + ttsMode = ttsMode.name + ) + + currentAuthToken = authToken preparationJob = scope.launch { prepareAndPlayFirstChunk() } } + fun forceStopWithError(errorMessage: String) { + scope.launch(Dispatchers.Main) { + _ttsState.value = _ttsState.value.copy( + isLoading = false, + isPlaying = false, + errorMessage = errorMessage + ) + handleStopTts(clearState = false) + } + } + private fun handleChangeSpeaker(newSpeakerId: String) { if (currentSpeakerId == newSpeakerId) return currentSpeakerId = newSpeakerId @@ -319,27 +411,52 @@ class TtsPlaybackManager( return } - val ttsAudioData = generateAudioChunk(firstChunk.text, currentSpeakerId, currentTtsMode) + val chunkStartTime = System.currentTimeMillis() + Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).") + + val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken) + Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms") + + if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { + withContext(Dispatchers.Main) { + _ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS") + handleStopTts(clearState = false) + } + return + } + val audioFile = ttsAudioData.audioFile + val streamUri = ttsAudioData.streamUri val serverText = ttsAudioData.serverText - if (audioFile != null && serverText != null) { - audioFiles[startAtIndex] = audioFile + if ((audioFile != null || streamUri != null) && serverText != null) { + if (audioFile != null) { + audioFiles[startAtIndex] = audioFile + } + loadedChunks.add(startAtIndex) val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings) val mutableChunks = textChunks.toMutableList() mutableChunks[startAtIndex] = updatedChunk textChunks = mutableChunks.toList() - val mediaItem = createMediaItem(serverText, audioFile.absolutePath, startAtIndex, updatedChunk) + if (streamUri != null) { + val uriStr = streamUri.toUri() + val id = uriStr.host ?: uriStr.lastPathSegment + if (id != null) chunkStreamIds[startAtIndex] = id + } + val pathToUse = streamUri ?: audioFile!!.absolutePath + val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk) withContext(Dispatchers.Main) { + val prepStartTime = System.currentTimeMillis() player.setMediaItem(mediaItem) player.prepare() if (startAtPosition > 0) { player.seekTo(startAtPosition) } player.playWhenReady = playWhenReady + Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms") _ttsState.value = _ttsState.value.copy( isLoading = false, isPlaying = playWhenReady, @@ -384,6 +501,8 @@ class TtsPlaybackManager( } private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) { + Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated") + onResetContext() preparationJob?.cancel() wordTrackingJob?.cancel() if (clearState) { @@ -401,8 +520,11 @@ class TtsPlaybackManager( player.stop() player.clearMediaItems() textChunks = emptyList() + lastPrefetchIndex = -1 + prefetchLoopJob?.cancel() prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.clear() + loadedChunks.clear() scope.launch { clearAudioFiles() @@ -411,6 +533,7 @@ class TtsPlaybackManager( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { val newPlaylistIndex = player.currentMediaItemIndex + Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason") if (newPlaylistIndex == C.INDEX_UNSET) return val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return @@ -437,8 +560,14 @@ class TtsPlaybackManager( val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull() if (previousChunkIndex != null) { - scope.launch { - audioFiles.remove(previousChunkIndex)?.delete() + scope.launch(Dispatchers.IO) { + val file = audioFiles.remove(previousChunkIndex) + deleteTempFile(file) + loadedChunks.remove(previousChunkIndex) + val streamId = chunkStreamIds.remove(previousChunkIndex) + if (streamId != null) { + StreamRegistry.remove(streamId) + } } } } @@ -485,114 +614,193 @@ class TtsPlaybackManager( _ttsState.value = nextState if (!isPlaying && player.playbackState == Player.STATE_IDLE) { - if (!nextState.sessionEndedByStop) { + if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) { + Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)") handleStopTts(userInitiated = true) + } else { + Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}") } } } override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - Timber.e(error, "Player error: ${error.message}") + Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}") _ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}") handleStopTts(userInitiated = true) } private fun prefetchNextChunkAudio(currentIndex: Int) { - for (i in 1..PREFETCH_LOOKAHEAD) { - val targetIndex = currentIndex + i - if (targetIndex < textChunks.size) { - if (prefetchingJobs.containsKey(targetIndex)) { - continue - } + if (currentIndex == lastPrefetchIndex && prefetchLoopJob?.isActive == true) { + return + } + lastPrefetchIndex = currentIndex - if (audioFiles.containsKey(targetIndex)) { - continue - } + prefetchLoopJob?.cancel() + prefetchLoopJob = scope.launch { + for (i in 1..PREFETCH_LOOKAHEAD) { + val targetIndex = currentIndex + i + if (targetIndex < textChunks.size) { + if (prefetchingJobs.containsKey(targetIndex)) continue + if (audioFiles.containsKey(targetIndex)) continue + if (loadedChunks.contains(targetIndex)) continue - Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex") + Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex") - val job = scope.launch { - val nextChunk = textChunks[targetIndex] - val ttsAudioData = generateAudioChunk(nextChunk.text, currentSpeakerId, currentTtsMode) - val audioFile = ttsAudioData.audioFile - val serverText = ttsAudioData.serverText + val job = launch { + val nextChunk = textChunks[targetIndex] + val prefetchStartTime = System.currentTimeMillis() + Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex") - if (audioFile != null && serverText != null) { - audioFiles[targetIndex] = audioFile + val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken) - val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) - val mutableChunks = textChunks.toMutableList() - mutableChunks[targetIndex] = updatedChunk - textChunks = mutableChunks.toList() + Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms") - val nextMediaItem = createMediaItem(serverText, audioFile.absolutePath, targetIndex, updatedChunk) - withContext(Dispatchers.Main) { - val wasLoading = _ttsState.value.isLoading - - var exists = false - for (k in 0 until player.mediaItemCount) { - if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) { - exists = true - break - } + if (ttsAudioData.error == "INSUFFICIENT_CREDITS") { + withContext(Dispatchers.Main) { + _ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS") + handleStopTts(clearState = false) } + return@launch + } - if (!exists) { - var insertPosition = player.mediaItemCount + val audioFile = ttsAudioData.audioFile + val streamUri = ttsAudioData.streamUri + val serverText = ttsAudioData.serverText + + if ((audioFile != null || streamUri != null) && serverText != null) { + val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) + val pathToUse = streamUri ?: audioFile!!.absolutePath + val nextMediaItem = createMediaItem(serverText, pathToUse, targetIndex, updatedChunk) + + withContext(Dispatchers.Main) { + if (audioFile != null) { + audioFiles[targetIndex] = audioFile + } + loadedChunks.add(targetIndex) + + val mutableChunks = textChunks.toMutableList() + mutableChunks[targetIndex] = updatedChunk + textChunks = mutableChunks.toList() + + if (streamUri != null) { + val uriStr = streamUri.toUri() + val id = uriStr.host ?: uriStr.lastPathSegment + if (id != null) chunkStreamIds[targetIndex] = id + } + + val wasLoading = _ttsState.value.isLoading + + var exists = false for (k in 0 until player.mediaItemCount) { - val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1 - if (id > targetIndex) { - insertPosition = k + if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) { + exists = true break } } - player.addMediaItem(insertPosition, nextMediaItem) - } - if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) { - player.seekToNextMediaItem() - player.play() - } else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) { - _ttsState.value = _ttsState.value.copy(isLoading = false) + if (!exists) { + var insertPosition = player.mediaItemCount + for (k in 0 until player.mediaItemCount) { + val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1 + if (id > targetIndex) { + insertPosition = k + break + } + } + player.addMediaItem(insertPosition, nextMediaItem) + } + + if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) { + player.seekToNextMediaItem() + player.play() + } else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) { + _ttsState.value = _ttsState.value.copy(isLoading = false) + } } + } else { + Timber.e("Prefetch: Failed to download chunk $targetIndex") } - } else { - Timber.e("Prefetch: Failed to download chunk $targetIndex") } - } - prefetchingJobs[targetIndex] = job - job.invokeOnCompletion { - prefetchingJobs.remove(targetIndex) + prefetchingJobs[targetIndex] = job + job.invokeOnCompletion { + prefetchingJobs.remove(targetIndex) + } + + job.join() } } } } private suspend fun trackWordByWord() { + var loopCount = 0 while (true) { + val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex } val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition } - val extras = currentMediaItem.mediaMetadata.extras ?: break - val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) ?: break - val offsets = extras.getIntArray(KEY_WORD_OFFSETS) ?: break - val sourceCfi = extras.getString("sourceCfi") ?: break + if (loopCount % 20 == 0) { + withContext(Dispatchers.Main) { player.playbackState } + withContext(Dispatchers.Main) { player.isPlaying } + } - val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition } + val uri = currentMediaItem.localConfiguration?.uri + if (uri?.scheme == "ttsstream") { + val streamId = uri.host ?: uri.lastPathSegment + if (streamId != null) { + val (isFinished, totalBytes) = StreamRegistry.getStreamMetadata(streamId) + if (isFinished && totalBytes > 44) { + val expectedDurationMs = (totalBytes - 44) / 48 - if (currentWordIndex != -1) { - val currentWordOffset = offsets[currentWordIndex] - if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) { - _ttsState.value = _ttsState.value.copy( - currentWordSourceCfi = sourceCfi, - currentWordStartOffset = currentWordOffset - ) + if (playbackPosition >= expectedDurationMs) { + Timber.tag("TTS_CLOUD_DIAG").i("Stream finished naturally: pos=$playbackPosition, expected=$expectedDurationMs. Transitioning.") + withContext(Dispatchers.Main) { + if (player.currentMediaItemIndex == currentIdx) { + if (player.hasNextMediaItem()) { + player.seekToNextMediaItem() + } else { + player.stop() + } + } + } + break + } + } } } - delay(100) + + val extras = currentMediaItem.mediaMetadata.extras ?: break + val sourceCfi = extras.getString("sourceCfi") ?: break + + val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) + val offsets = extras.getIntArray(KEY_WORD_OFFSETS) + + if (timestamps != null && offsets != null) { + val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition } + if (currentWordIndex != -1) { + val currentWordOffset = offsets[currentWordIndex] + if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) { + _ttsState.value = _ttsState.value.copy( + currentWordSourceCfi = sourceCfi, + currentWordStartOffset = currentWordOffset + ) + } + } + } + + delay(50) + loopCount++ } } + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + Timber.tag("TTS_CLOUD_DIAG").d("onPlayWhenReadyChanged: playWhenReady=$playWhenReady, reason=$reason") + } + + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { + Timber.tag("TTS_CLOUD_DIAG").d("onPositionDiscontinuity: reason=$reason") + } + private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem { val extras = Bundle().apply { putString("sourceCfi", chunk.sourceCfi) @@ -615,17 +823,30 @@ class TtsPlaybackManager( .setExtras(extras) .build() + val uri = if (path.startsWith("ttsstream://")) path.toUri() else Uri.fromFile(File(path)) + return MediaItem.Builder() - .setUri(Uri.fromFile(File(path))) + .setUri(uri) .setMediaId(index.toString()) .setMediaMetadata(metadata) .build() } + private fun deleteTempFile(file: File?) { + file?.let { + if (it.name.startsWith("tts_audio_chunk_") || it.name.startsWith("base_tts_") || it.name.startsWith("tts_live_")) { + it.delete() + } + } + } + private suspend fun clearAudioFiles() { withContext(Dispatchers.IO) { - audioFiles.values.forEach { it.delete() } + audioFiles.values.forEach { deleteTempFile(it) } audioFiles.clear() + chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED + chunkStreamIds.clear() // ADDED + loadedChunks.clear() } } @@ -640,6 +861,7 @@ class TtsPlaybackManager( putInt("currentWordStartOffset", state.currentWordStartOffset) putBoolean("sessionFinished", state.sessionFinished) putString("playbackSource", state.playbackSource) + putString("ttsMode", state.ttsMode) } return CommandButton.Builder() .setSessionCommand(STATE_UPDATE_COMMAND) @@ -662,4 +884,15 @@ class TtsPlaybackManager( handleStopTts(userInitiated = true) Timber.d("TtsPlaybackManager released.") } + + override fun onPlaybackStateChanged(playbackState: Int) { + val stateName = when (playbackState) { + Player.STATE_IDLE -> "STATE_IDLE" + Player.STATE_BUFFERING -> "STATE_BUFFERING" + Player.STATE_READY -> "STATE_READY" + Player.STATE_ENDED -> "STATE_ENDED" + else -> "UNKNOWN" + } + Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName") + } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/tts/TtsService.kt b/app/src/main/java/com/aryan/reader/tts/TtsService.kt index 057687a..69afa67 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsService.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsService.kt @@ -23,8 +23,6 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build -import android.util.Base64 -import timber.log.Timber import androidx.core.content.ContextCompat import androidx.media3.common.AudioAttributes import androidx.media3.common.C @@ -33,23 +31,33 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.aryan.reader.tts.TtsPlaybackManager.TtsMode -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.File -import java.io.FileOutputStream -import java.net.HttpURLConnection -import java.net.URL import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock data class WordTimingInfo(val word: String, val startTime: Double) + data class TtsAudioData( val audioFile: File?, val serverText: String?, - val wordTimings: List? + val wordTimings: List?, + val error: String? = null, + val streamUri: String? = null ) data class PageCharacterRange( @@ -59,6 +67,165 @@ data class PageCharacterRange( val endOffset: Int ) +class ConcurrentInputStream : java.io.InputStream() { + private val queue = java.util.concurrent.LinkedBlockingQueue() + private var currentBuffer: ByteArray? = null + private var bufferPos = 0 + private var eofReached = false + + var isFinished = false + private set + + var isClosed = false + private set + + fun write(data: ByteArray) { + if (!isClosed) queue.offer(data) + } + + override fun read(): Int { + val b = ByteArray(1) + val readCount = read(b, 0, 1) + return if (readCount == -1) -1 else b[0].toInt() and 0xFF + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (eofReached) { + isFinished = true + return -1 + } + if (len == 0) return 0 + + if (currentBuffer == null || bufferPos >= currentBuffer!!.size) { + try { + // Blocks here safely until data arrives + currentBuffer = queue.take() + bufferPos = 0 + if (currentBuffer!!.isEmpty()) { + eofReached = true + isFinished = true + return -1 + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return -1 + } + } + + val available = currentBuffer!!.size - bufferPos + val toCopy = len.coerceAtMost(available) + System.arraycopy(currentBuffer!!, bufferPos, b, off, toCopy) + bufferPos += toCopy + return toCopy + } + + override fun close() { + if (!isClosed) { + isClosed = true + queue.offer(ByteArray(0)) // Send EOF marker + } + } +} + +object StreamRegistry { + private val streams = java.util.concurrent.ConcurrentHashMap() + private val totalBytesMap = java.util.concurrent.ConcurrentHashMap() + private val finishedMap = java.util.concurrent.ConcurrentHashMap() + + fun register(id: String, stream: java.io.InputStream) { + streams[id] = stream + totalBytesMap[id] = 0L + finishedMap[id] = false + } + fun get(id: String): java.io.InputStream? = streams[id] + + fun markFinished(id: String, totalBytes: Long) { + totalBytesMap[id] = totalBytes + finishedMap[id] = true + } + fun getStreamMetadata(id: String): Pair { + return (finishedMap[id] ?: false) to (totalBytesMap[id] ?: 0L) + } + + fun remove(id: String) { + streams.remove(id)?.let { try { it.close() } catch (_: Exception) {} } + totalBytesMap.remove(id) + finishedMap.remove(id) + } + fun clear() { + streams.values.forEach { try { it.close() } catch (_: Exception) {} } + streams.clear() + } +} + +@UnstableApi +class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) { + private var inputStream: java.io.InputStream? = null + private var opened = false + private var uri: android.net.Uri? = null + private var bytesReadTotal: Long = 0 + + override fun open(dataSpec: androidx.media3.datasource.DataSpec): Long { + uri = dataSpec.uri + Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource.open called for $uri, position=${dataSpec.position}") + + val streamId = uri?.host ?: uri?.lastPathSegment ?: throw java.io.IOException("No stream ID") + val stream = StreamRegistry.get(streamId) ?: throw java.io.IOException("Stream not found") + + if (stream is ConcurrentInputStream && stream.isFinished) { + Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource.open returning 0 bytes for finished stream to prevent retry.") + opened = true + transferInitializing(dataSpec) + transferStarted(dataSpec) + return 0 + } + + inputStream = stream + opened = true + transferInitializing(dataSpec) + + if (dataSpec.position > bytesReadTotal) { + val toSkip = dataSpec.position - bytesReadTotal + var skipped = 0L + while (skipped < toSkip) { + val s = inputStream?.skip(toSkip - skipped) ?: 0L + if (s <= 0L) break + skipped += s + } + bytesReadTotal += skipped + } + + transferStarted(dataSpec) + return C.LENGTH_UNSET.toLong() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (length == 0) return 0 + return try { + val bytesRead = inputStream?.read(buffer, offset, length) ?: -1 + if (bytesRead == -1) { + Timber.tag("TTS_CLOUD_DIAG").d("InputStreamDataSource EOF reached for $uri") + return C.RESULT_END_OF_INPUT + } + bytesReadTotal += bytesRead + bytesTransferred(bytesRead) + bytesRead + } catch (e: java.io.IOException) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Stream read interrupted/broken for $uri") + C.RESULT_END_OF_INPUT + } + } + + override fun getUri(): android.net.Uri? = uri + + override fun close() { + if (opened) { + opened = false + transferEnded() + } + } +} + @UnstableApi class TtsService : MediaSessionService() { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -66,6 +233,7 @@ class TtsService : MediaSessionService() { private lateinit var player: ExoPlayer private lateinit var playbackManager: TtsPlaybackManager private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer + private lateinit var cacheManager: TtsCacheManager override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -81,116 +249,317 @@ class TtsService : MediaSessionService() { super.onUpdateNotification(session, startInForegroundRequired) } - /** - * Generic function to download TTS audio from a server endpoint. - * This is used for both the self-hosted server and the Google Cloud worker. - * - * @param chunkToSpeak The text to synthesize. - * @param speakerId The identifier for the voice. - * @param serverUrl The base URL of the TTS server. - * @param audioFileExtension The file extension for the temporary audio file (e.g., ".flac", ".mp3"). - * @return A pair containing the temporary audio file and the text chunk returned by the server, or null if it fails. - */ - private suspend fun downloadFromTtsServer( - chunkToSpeak: String, - speakerId: String, - serverUrl: String, - audioFileExtension: String - ): TtsAudioData { - if (chunkToSpeak.isBlank()) { - return TtsAudioData(null, null, null) - } - return withContext(Dispatchers.IO) { - var tempAudioFile: File? = null - try { - val url = URL(serverUrl) - val 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 = 60000 - connection.doOutput = true - connection.doInput = true - - val jsonPayload = JSONObject() - jsonPayload.put("text", chunkToSpeak) - jsonPayload.put("speaker", speakerId) - val jsonInputString = jsonPayload.toString() - connection.outputStream.use { os -> - val input = jsonInputString.toByteArray(Charsets.UTF_8) - os.write(input, 0, input.size) - } - - val responseCode = connection.responseCode - if (responseCode != HttpURLConnection.HTTP_OK) { - val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { "" } - Timber.e("TTS Server request failed with code: $responseCode for URL: $serverUrl. Body: $errorBody") - return@withContext TtsAudioData(null, null, null) - } - - val responseBody = - connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } - val jsonResponse = JSONObject(responseBody) - if (jsonResponse.has("audio_base64") && jsonResponse.has("text_chunk")) { - val audioBase64 = jsonResponse.getString("audio_base64") - val serverTextChunk = jsonResponse.getString("text_chunk") - val audioBytes = Base64.decode(audioBase64, Base64.DEFAULT) - - val wordTimings = mutableListOf() - if (jsonResponse.has("word_timings")) { - val timingsArray: JSONArray = jsonResponse.getJSONArray("word_timings") - for (i in 0 until timingsArray.length()) { - val timingObject = timingsArray.getJSONObject(i) - wordTimings.add( - WordTimingInfo( - word = timingObject.getString("word"), - startTime = timingObject.getDouble("startTime") - ) - ) - } - } - - tempAudioFile = File.createTempFile( - "tts_audio_chunk_", - audioFileExtension, - applicationContext.cacheDir - ) - FileOutputStream(tempAudioFile).use { output -> output.write(audioBytes) } - TtsAudioData(tempAudioFile, serverTextChunk, wordTimings) - } else { - Timber.e("DownloadAudioChunk: 'audio_base64' or 'text_chunk' field missing." - ) - TtsAudioData(null, null, null) - } - } catch (e: Exception) { - Timber.e(e, "DownloadAudioChunk: TTS Request Exception: ${e.message}") - tempAudioFile?.delete() - TtsAudioData(null, null, null) + private val okHttpClient = OkHttpClient.Builder().build() + private val liveClient by lazy { + GeminiLiveClient(okHttpClient) { errorMsg -> + if (::playbackManager.isInitialized) { + playbackManager.forceStopWithError(errorMsg) } } } - private val downloadAudioChunk: suspend (String, String) -> TtsAudioData = - { chunkToSpeak, speakerId -> - downloadFromTtsServer( - chunkToSpeak, - speakerId, - googleCloudWorkerTtsUrl, - ".mp3" - ) + class GeminiLiveClient( + private val client: OkHttpClient, + private val onAsyncError: (String) -> Unit = {} + ) { + private var webSocket: WebSocket? = null + + private val connectionMutex = Mutex() + private val generationMutex = Mutex() + private var clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var audioChannel = Channel(Channel.UNLIMITED) + private var setupDeferred = CompletableDeferred().apply { complete(false) } + + var connectedSpeaker: String? = null + + sealed class GeminiWsEvent { + data class Audio(val bytes: ByteArray) : GeminiWsEvent() + object TurnComplete : GeminiWsEvent() + data class Error(val message: String) : GeminiWsEvent() } + suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock { + if (webSocket != null) { + if (connectedSpeaker == speaker) { + val isSetup = try { setupDeferred.await() } catch(_: Exception) { false } + if (isSetup) return@withLock + } + Timber.tag("TTS_CLOUD_DIAG").d("Closing existing WS. Speaker changed or setup failed.") + webSocket?.close(1000, "Reconnecting") + webSocket = null + } + + val sanitizedUrl = serverUrl.removeSuffix("/") + val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://") + val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}" + + Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url") + val request = Request.Builder().url(url).build() + val connectedDeferred = CompletableDeferred() + + var connectionError: String? = null + + setupDeferred = CompletableDeferred() + connectedSpeaker = speaker + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Timber.tag("TTS_CLOUD_DIAG").d("WS Opened. Sending Setup configuration to Gemini...") + + val systemPrompt = """ + You are a professional audiobook narrator. + Your ONLY task is to read the exact text provided to you, word for word, neutral emotion, and with good pacing. + Do NOT add any conversational filler, acknowledgments, or extra words (e.g., do not say "Sure, here is the text"). + Do NOT skip any parts or summarize. Output ONLY the audio reading of the provided text. If you encounter unreadable, non-verbal, or non-linguistic content (e.g., symbols like "※▼◆", raw formatting markers, broken characters, or pure punctuation clusters with no readable words), silently skip it and continue reading. + """.trimIndent() + + val setupMsg = JSONObject().apply { + put("setup", JSONObject().apply { + put("model", "models/gemini-3.1-flash-live-preview") + put("systemInstruction", JSONObject().apply { + put("parts", org.json.JSONArray().apply { + put(JSONObject().apply { + put("text", systemPrompt) + }) + }) + }) + put("generationConfig", JSONObject().apply { + put("responseModalities", org.json.JSONArray().apply { put("AUDIO") }) + put("speechConfig", JSONObject().apply { + put("voiceConfig", JSONObject().apply { + put("prebuiltVoiceConfig", JSONObject().apply { + put("voiceName", speaker) + }) + }) + }) + }) + }) + }.toString() + + webSocket.send(setupMsg) + connectedDeferred.complete(true) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val json = JSONObject(text) + if (json.has("error")) { + val errObj = json.opt("error") + val errMsg = if (errObj is JSONObject) errObj.toString() else errObj?.toString() ?: "Unknown API Error" + Timber.tag("TTS_CLOUD_DIAG").e("API ERROR RETURNED: $errMsg") + audioChannel.trySend(GeminiWsEvent.Error(errMsg)) + setupDeferred.complete(false) + return + } + if (json.has("setupComplete")) { + setupDeferred.complete(true) + } + + val serverContent = json.optJSONObject("serverContent") + if (serverContent != null) { + val turnComplete = serverContent.optBoolean("turnComplete", false) + val modelTurn = serverContent.optJSONObject("modelTurn") + val parts = modelTurn?.optJSONArray("parts") + + if (parts != null) { + for (i in 0 until parts.length()) { + val part = parts.getJSONObject(i) + val inlineData = part.optJSONObject("inlineData") + if (inlineData != null) { + val b64 = inlineData.optString("data") + if (b64.isNotEmpty()) { + val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT) + audioChannel.trySend(GeminiWsEvent.Audio(bytes)) + } + } + } + } + + if (turnComplete) { + audioChannel.trySend(GeminiWsEvent.TurnComplete) + } + } + } catch (e: Exception) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Error parsing WS message text") + } + } + + override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { + onMessage(webSocket, bytes.utf8()) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + connectionError = if (response?.code == 402) { + "INSUFFICIENT_CREDITS" + } else { + "WS Failure: ${t.message} | Response: ${response?.code}" + } + Timber.tag("TTS_CLOUD_DIAG").e(t) + audioChannel.trySend(GeminiWsEvent.Error(connectionError)) + this@GeminiLiveClient.webSocket = null + connectedDeferred.complete(false) + setupDeferred.complete(false) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + audioChannel.trySend(GeminiWsEvent.Error("Connection Closed: $reason")) + this@GeminiLiveClient.webSocket = null + setupDeferred.complete(false) + } + }) + + val isConnected = connectedDeferred.await() + if (!isConnected) throw IllegalStateException(connectionError ?: "Failed to connect to proxy WebSocket") + + val isSetup = try { + kotlinx.coroutines.withTimeout(10000L) { setupDeferred.await() } + } catch (_: Exception) { false } + + if (!isSetup) { + webSocket?.close(1000, "Setup failed") + webSocket = null + connectedSpeaker = null + throw IllegalStateException("Failed to complete Gemini setup") + } else { + Timber.tag("TTS_CLOUD_DIAG").d("Gemini setup complete") + } + } + + fun generateChunk(text: String, cacheFile: File?): TtsAudioData { + if (text.isBlank()) return TtsAudioData(null, null, null, "Text is blank") + + val streamId = java.util.UUID.randomUUID().toString() + val concurrentStream = ConcurrentInputStream() + StreamRegistry.register(streamId, concurrentStream) + val header = createWavHeaderUnknownLength(24000) + concurrentStream.write(header) + + clientScope.launch { + generationMutex.withLock { + var fileOutputStream: java.io.FileOutputStream? = null + var tempFile: File? = null + + try { + if (!isActive) return@launch + + // Prepare cache temp file + if (cacheFile != null) { + tempFile = File(cacheFile.absolutePath + ".tmp") + fileOutputStream = java.io.FileOutputStream(tempFile) + fileOutputStream.write(header) + } + + Timber.tag("TTS_CLOUD_DIAG").d("Starting API generation task for chunk: ${text.take(15)}...") + + audioChannel = Channel(Channel.UNLIMITED) + val chunkGenStartTime = System.currentTimeMillis() + var firstByteTime = -1L + + val payload = JSONObject().apply { + put("realtimeInput", JSONObject().apply { + put("text", text) + }) + }.toString() + + val sent = webSocket?.send(payload) ?: false + if (!sent) { + Timber.tag("TTS_CLOUD_DIAG").e("Failed to send text payload over WS") + return@launch + } + + var receivedAudioBytes = 0 + kotlinx.coroutines.withTimeout(30000L) { + for (event in audioChannel) { + when (event) { + is GeminiWsEvent.Audio -> { + if (firstByteTime == -1L) { + firstByteTime = System.currentTimeMillis() + Timber.tag("TTS_CLOUD_DIAG").i("TTFB: ${firstByteTime - chunkGenStartTime}ms") + } + concurrentStream.write(event.bytes) + fileOutputStream?.write(event.bytes) + receivedAudioBytes += event.bytes.size + } + is GeminiWsEvent.TurnComplete -> { + Timber.tag("TTS_CLOUD_DIAG").i("Chunk generation complete. Bytes: $receivedAudioBytes") + StreamRegistry.markFinished(streamId, receivedAudioBytes.toLong() + 44) + + fileOutputStream?.close() + fileOutputStream = null + if (tempFile != null && cacheFile != null && receivedAudioBytes > 0) { + patchWavHeader(tempFile, receivedAudioBytes) + tempFile.renameTo(cacheFile) + Timber.tag("TTS_CLOUD_DIAG").d("Successfully cached chunk to ${cacheFile.name}") + } + break + } + is GeminiWsEvent.Error -> { + Timber.tag("TTS_CLOUD_DIAG").e("WS Error received: ${event.message}") + onAsyncError(event.message) + break + } + } + } + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Timeout waiting for audio/TurnComplete") + } catch (e: kotlinx.coroutines.CancellationException) { + Timber.tag("TTS_CLOUD_DIAG").i(e, "Streaming job cancelled due to user skip/flush") + } catch (e: Exception) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Exception piping audio") + } finally { + Timber.tag("TTS_CLOUD_DIAG").d("Closing stream for ${text.take(15)}") + concurrentStream.close() + fileOutputStream?.close() + if (cacheFile != null && !cacheFile.exists()) { + tempFile?.delete() + } + } + } + } + + return TtsAudioData(null, text, emptyList(), streamUri = "ttsstream://$streamId") + } + + fun close() { + clientScope.cancel() + clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + webSocket?.close(1000, "Context Reset") + webSocket = null + connectedSpeaker = null + setupDeferred = CompletableDeferred().apply { complete(false) } + StreamRegistry.clear() + } + } + private val synthesizeBaseTtsChunk: suspend (String) -> TtsAudioData = { chunkToSpeak -> val (file, text) = baseTtsSynthesizer.synthesizeToFile(chunkToSpeak) TtsAudioData(file, text, null) } - private val audioGenerator: suspend (text: String, speaker: String, mode: TtsMode) -> TtsAudioData = - { text, speaker, mode -> + val audioGenerator: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, text: String, speaker: String, mode: TtsMode, authToken: String?) -> TtsAudioData = + { bookTitle, chapterTitle, chunkIndex, totalChunks, text, speaker, mode, authToken -> + cacheManager.saveTotalChunks(bookTitle, chapterTitle, totalChunks) when (mode) { - TtsMode.CLOUD -> downloadAudioChunk(text, speaker) + TtsMode.CLOUD -> { + val cachedFile = cacheManager.getCacheFile(bookTitle, chapterTitle, text, speaker, mode) + + if (cachedFile.exists() && cachedFile.length() > 44) { + Timber.tag("TTS_CLOUD_DIAG").i("Using cached audio for chunk $chunkIndex") + TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null) + } else { + try { + liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken) + liveClient.generateChunk(text, cachedFile) + } catch (e: Exception) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed") + TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service") + } + } + } TtsMode.BASE -> synthesizeBaseTtsChunk(text) } } @@ -199,6 +568,8 @@ class TtsService : MediaSessionService() { super.onCreate() Timber.d("TtsService created.") + cacheManager = TtsCacheManager(this) + baseTtsSynthesizer = BaseTtsSynthesizer(this) scope.launch { try { @@ -213,14 +584,49 @@ class TtsService : MediaSessionService() { .setUsage(C.USAGE_MEDIA) .build() + val defaultDataSourceFactory = androidx.media3.datasource.DefaultDataSource.Factory(this) + val dataSourceFactory = androidx.media3.datasource.DataSource.Factory { + object : androidx.media3.datasource.DataSource { + private var dataSource: androidx.media3.datasource.DataSource? = null + private val defaultDataSource = defaultDataSourceFactory.createDataSource() + private val streamDataSource = InputStreamDataSource() + + override fun addTransferListener(transferListener: androidx.media3.datasource.TransferListener) { + defaultDataSource.addTransferListener(transferListener) + streamDataSource.addTransferListener(transferListener) + } + + override fun open(dataSpec: androidx.media3.datasource.DataSpec): Long { + dataSource = if (dataSpec.uri.scheme == "ttsstream") { + streamDataSource + } else { + defaultDataSource + } + return dataSource!!.open(dataSpec) + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return dataSource!!.read(buffer, offset, length) + } + + override fun getUri(): android.net.Uri? = dataSource?.uri + + override fun close() { + dataSource?.close() + } + } + } + player = ExoPlayer.Builder(this) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) + .setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory)) .build() playbackManager = TtsPlaybackManager( player = player, - generateAudioChunk = audioGenerator + generateAudioChunk = audioGenerator, + onResetContext = { liveClient.close() } ) mediaSession = MediaSession.Builder(this, player) diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt index 9dd3882..efb8830 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt @@ -21,36 +21,189 @@ package com.aryan.reader.tts import android.content.Context import android.media.MediaPlayer -import timber.log.Timber +import androidx.annotation.OptIn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.net.toUri +import androidx.media3.common.util.UnstableApi import com.aryan.reader.BuildConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.HttpURLConnection -import java.net.URL +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.security.MessageDigest +import kotlin.math.ln +import kotlin.math.pow const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL -const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark." - const val TTS_CHUNK_MAX_LENGTH = 250 +const val DEFAULT_SPEAKER_ID = "Aoede" -const val DEFAULT_SPEAKER_ID = "en-US-Standard-F" +data class GeminiVoice(val id: String, val name: String, val description: String) -@Suppress("unused") -val GOOGLE_TTS_SPEAKERS = listOf( - "US Female: F" to "en-US-Standard-F", - "US Female: H" to "en-US-Standard-H", - "US Male: I" to "en-US-Standard-I", - "US Male: J" to "en-US-Standard-J" +val GEMINI_TTS_SPEAKERS = listOf( + GeminiVoice("Zephyr", "Zephyr", "Bright, Higher pitch"), + GeminiVoice("Puck", "Puck", "Upbeat, Middle pitch"), + GeminiVoice("Charon", "Charon", "Informative, Lower pitch"), + GeminiVoice("Kore", "Kore", "Firm, Middle pitch"), + GeminiVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"), + GeminiVoice("Leda", "Leda", "Youthful, Higher pitch"), + GeminiVoice("Orus", "Orus", "Firm, Lower middle pitch"), + GeminiVoice("Aoede", "Aoede", "Breezy, Middle pitch"), + GeminiVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"), + GeminiVoice("Autonoe", "Autonoe", "Bright, Middle pitch"), + GeminiVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"), + GeminiVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"), + GeminiVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"), + GeminiVoice("Algieba", "Algieba", "Smooth, Lower pitch"), + GeminiVoice("Despina", "Despina", "Smooth, Middle pitch"), + GeminiVoice("Erinome", "Erinome", "Clear, Middle pitch"), + GeminiVoice("Algenib", "Algenib", "Gravelly, Lower pitch"), + GeminiVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"), + GeminiVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"), + GeminiVoice("Achernar", "Achernar", "Soft, Higher pitch"), + GeminiVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"), + GeminiVoice("Schedar", "Schedar", "Even, Lower middle pitch"), + GeminiVoice("Gacrux", "Gacrux", "Mature, Middle pitch"), + GeminiVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"), + GeminiVoice("Achird", "Achird", "Friendly, Lower middle pitch"), + GeminiVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"), + GeminiVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"), + GeminiVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"), + GeminiVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"), + GeminiVoice("Sulafat", "Sulafat", "Warn, Middle pitch"), ) +data class TtsChapterCacheInfo( + val chapterTitle: String, + val chunkCount: Int, + val totalChunks: Int?, + val sizeBytes: Long, + val directory: File, + val matchingFiles: List = emptyList() +) + +fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt() + val pre = "KMGTPE"[exp - 1] + return String.format("%.1f %cB", bytes / 1024.0.pow(exp.toDouble()), pre) +} + +class TtsCacheManager(private val context: Context) { + private fun sanitize(name: String): String = name.replace(Regex("[^a-zA-Z0-9.-]"), "_") + + private fun hash(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) }.take(16) + } + + fun saveTotalChunks(bookTitle: String, chapterTitle: String?, totalChunks: Int) { + val baseDir = File(context.filesDir, "TTS_Cache") + val bookDir = File(baseDir, sanitize(bookTitle.take(50))) + val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + if (!chapterDir.exists()) chapterDir.mkdirs() + val metaFile = File(chapterDir, "total_chunks.txt") + metaFile.writeText(totalChunks.toString()) + } + + @OptIn(UnstableApi::class) + fun getCacheFile( + bookTitle: String, + chapterTitle: String?, + text: String, + speakerId: String, + mode: TtsPlaybackManager.TtsMode + ): File { + val baseDir = File(context.filesDir, "TTS_Cache") + val bookDir = File(baseDir, sanitize(bookTitle.take(50))) + val chapterDir = File(bookDir, sanitize((chapterTitle ?: "Unknown_Chapter").take(50))) + if (!chapterDir.exists()) { + chapterDir.mkdirs() + } + + val hashParams = hash(text + speakerId + mode.name) + val safeSpeaker = sanitize(speakerId) + + return File(chapterDir, "cached_chunk_${safeSpeaker}_$hashParams.wav") + } + + fun getBookCacheDir(bookTitle: String): File { + val baseDir = File(context.filesDir, "TTS_Cache") + return File(baseDir, sanitize(bookTitle.take(50))) + } + + fun getChapterCaches(bookTitle: String, speakerFilter: String? = null): List { + val bookDir = getBookCacheDir(bookTitle) + if (!bookDir.exists()) return emptyList() + + return bookDir.listFiles()?.filter { it.isDirectory }?.mapNotNull { chapterDir -> + val files = chapterDir.listFiles()?.filter { file -> + if (!file.isFile || !file.name.endsWith(".wav")) return@filter false + if (speakerFilter == null || speakerFilter == "All") return@filter true + + val parts = file.name.split("_") + + val speakerInName = if (parts.size >= 5 && parts[2].all { it.isDigit() }) { + parts[3] + } else if (parts.size >= 4) { + parts[2] + } else null + + speakerInName == speakerFilter + } ?: emptyList() + + if (files.isEmpty()) null + else { + val size = files.sumOf { it.length() } + val metaFile = File(chapterDir, "total_chunks.txt") + val total = if (metaFile.exists()) metaFile.readText().toIntOrNull() else null + + TtsChapterCacheInfo( + chapterTitle = chapterDir.name, + chunkCount = files.size, + totalChunks = total, + sizeBytes = size, + directory = chapterDir, + matchingFiles = files + ) + } + }?.sortedBy { it.chapterTitle } ?: emptyList() + } + + fun deleteChapterCache(chapterDir: File) { + chapterDir.deleteRecursively() + } + + fun deleteSpecificFiles(files: List, chapterDir: File) { + files.forEach { it.delete() } + if (chapterDir.listFiles()?.isEmpty() == true) { + chapterDir.deleteRecursively() + } + } + + fun clearBookCache(bookTitle: String) { + getBookCacheDir(bookTitle).deleteRecursively() + } +} + +fun patchWavHeader(file: File, pcmDataLength: Int) { + try { + RandomAccessFile(file, "rw").use { raf -> + raf.seek(4) + raf.writeInt(Integer.reverseBytes(36 + pcmDataLength)) + raf.seek(40) + raf.writeInt(Integer.reverseBytes(pcmDataLength)) + } + } catch (e: Exception) { + Timber.tag("TTS_CLOUD_DIAG").e(e, "Failed to patch WAV header for cached file") + } +} + fun splitTextIntoChunks(text: String, maxLengthPerChunk: Int = TTS_CHUNK_MAX_LENGTH): List { if (text.isBlank()) return emptyList() val sentenceBoundaryRegex = Regex("""(? String? ) { private val sampleMediaPlayer = MediaPlayer() var loadingSpeakerId by mutableStateOf(null) var playingSpeakerId by mutableStateOf(null) + val cachedSpeakers = androidx.compose.runtime.mutableStateListOf() + + private val httpClient = okhttp3.OkHttpClient() + @OptIn(UnstableApi::class) + private val liveClient = TtsService.GeminiLiveClient(httpClient) + init { + // Read initially existing files + scope.launch(Dispatchers.IO) { + val files = context.cacheDir.listFiles { _, name -> name.startsWith("sample_") && name.endsWith(".wav") } + val ids = files?.map { it.name.removePrefix("sample_").removeSuffix(".wav") } ?: emptyList() + withContext(Dispatchers.Main) { + cachedSpeakers.addAll(ids) + } + } + sampleMediaPlayer.setOnErrorListener { mp, what, extra -> Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.") playingSpeakerId = null loadingSpeakerId = null - try { - mp.reset() - } catch (e: IllegalStateException) { - Timber.e("Error resetting MediaPlayer: ${e.message}") - } + try { mp.reset() } catch (_: Exception) {} true } } - @Suppress("unused") fun playOrStop(speakerId: String) { scope.launch { + liveClient.close() when { playingSpeakerId == speakerId -> { sampleMediaPlayer.stop() @@ -122,51 +288,49 @@ class SpeakerSamplePlayer( loadingSpeakerId == speakerId -> { loadingSpeakerId = null } - else -> playSample(speakerId) + else -> { + playSample(speakerId) + } } } } + @OptIn(UnstableApi::class) private suspend fun playSample(speakerId: String) { - if (sampleMediaPlayer.isPlaying) { - sampleMediaPlayer.stop() - } + if (sampleMediaPlayer.isPlaying) sampleMediaPlayer.stop() sampleMediaPlayer.reset() loadingSpeakerId = speakerId playingSpeakerId = null withContext(Dispatchers.IO) { + val cacheFile = File(context.cacheDir, "sample_$speakerId.wav") try { - val url = URL(googleCloudWorkerTtsUrl) - val 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 = 30000 - connection.doOutput = true - connection.doInput = true + if (!cacheFile.exists()) { + val bucketName = "reader-9fc469d7.firebasestorage.app" + val sampleUrl = "https://firebasestorage.googleapis.com/v0/b/$bucketName/o/samples%2Fsample_${speakerId}.wav?alt=media" - val jsonPayload = JSONObject().apply { - put("text", TTS_SAMPLE_TEXT) - put("speaker", speakerId) - } - connection.outputStream.use { os -> - os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) - } + val request = okhttp3.Request.Builder() + .url(sampleUrl) + .build() - - if (connection.responseCode == HttpURLConnection.HTTP_OK) { - val responseBody = connection.inputStream.bufferedReader().use { it.readText() } - val audioBase64 = JSONObject(responseBody).getString("audio_base64") - - val dataUri = "data:audio/mpeg;base64,$audioBase64" - - withContext(Dispatchers.Main) { - if (loadingSpeakerId != speakerId) { - return@withContext + val response = httpClient.newCall(request).execute() + if (response.isSuccessful) { + response.body?.byteStream()?.use { input -> + cacheFile.outputStream().use { output -> + input.copyTo(output) + } } - sampleMediaPlayer.setDataSource(context, dataUri.toUri()) + } else { + Timber.e("Failed to download sample for $speakerId. HTTP ${response.code}") + throw Exception("Failed to cache sample") + } + } + + if (cacheFile.exists()) { + withContext(Dispatchers.Main) { + if (!cachedSpeakers.contains(speakerId)) cachedSpeakers.add(speakerId) + if (loadingSpeakerId != speakerId) return@withContext + sampleMediaPlayer.setDataSource(cacheFile.absolutePath) sampleMediaPlayer.setOnPreparedListener { mp -> if (loadingSpeakerId == speakerId) { mp.start() @@ -180,16 +344,54 @@ class SpeakerSamplePlayer( sampleMediaPlayer.prepareAsync() } } else { - Timber.e("Failed to fetch sample for $speakerId. Code: ${connection.responseCode}") - withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null } + throw Exception("Sample file missing after download attempt") } } catch (e: Exception) { - Timber.e(e, "Exception playing sample for $speakerId: ${e.message}") + Timber.e(e, "Exception playing sample for $speakerId") withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null } } } } + + fun clearSamples() { + scope.launch(Dispatchers.IO) { + val files = context.cacheDir.listFiles { _, name -> name.startsWith("sample_") && name.endsWith(".wav") } + files?.forEach { it.delete() } + withContext(Dispatchers.Main) { + cachedSpeakers.clear() + } + } + } + + @OptIn(UnstableApi::class) fun release() { sampleMediaPlayer.release() + liveClient.close() } +} + +fun createWavHeaderUnknownLength(sampleRate: Int): ByteArray { + val numChannels = 1 + val bitsPerSample = 16 + val byteRate = sampleRate * numChannels * bitsPerSample / 8 + val blockAlign = numChannels * bitsPerSample / 8 + + val header = java.nio.ByteBuffer.allocate(44) + header.order(java.nio.ByteOrder.LITTLE_ENDIAN) + + header.put("RIFF".toByteArray(Charsets.US_ASCII)) + header.putInt(0x7FFFFFFF) + header.put("WAVE".toByteArray(Charsets.US_ASCII)) + header.put("fmt ".toByteArray(Charsets.US_ASCII)) + header.putInt(16) + header.putShort(1.toShort()) + header.putShort(numChannels.toShort()) + header.putInt(sampleRate) + header.putInt(byteRate) + header.putShort(blockAlign.toShort()) + header.putShort(bitsPerSample.toShort()) + header.put("data".toByteArray(Charsets.US_ASCII)) + header.putInt(0x7FFFFFFF - 36) + + return header.array() } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98f85cd..ceb6765 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,19 +245,19 @@ Basic Dictionary Look up single words quickly Current Plan - - 50%% OFF + + 50% OFF Loading price… One-time payment Lifetime Access Early Access Sale - Everything in Free, plus: + Features: Cloud Sync Across Devices Keep your entire library, including book files and reading progress, synced across up to 4 devices. Summarization - Get quick summaries of chapters or pages + Get 10 free summaries of chapters or pages per day Smart Dictionary Search phrases and even paragraphs, not just single words @@ -272,6 +272,7 @@ Upgrade currently unavailable. Please check your internet and try again. Please sign in to your Google account to purchase Episteme Pro. + Please sign in to your Google account to purchase credits. This may take a few moments. Your Pro status will be updated automatically. This device already has a Pro purchase, but it\'s linked to a different account. Please sign in to the account that was used for the original purchase to restore your Pro features. @@ -511,7 +512,7 @@ Search in book… No results found. - + Generating summary… Stop Read aloud diff --git a/app/src/oss/java/com/aryan/reader/Auth.kt b/app/src/oss/java/com/aryan/reader/Auth.kt index 562469f..1ede8d4 100644 --- a/app/src/oss/java/com/aryan/reader/Auth.kt +++ b/app/src/oss/java/com/aryan/reader/Auth.kt @@ -22,4 +22,6 @@ class AuthRepository(private val applicationContext: Context) { fun observeAuthState(): Flow { return flowOf(null) } + + suspend fun getIdToken(): String? = null } \ No newline at end of file diff --git a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt index 67f030b..0e5f22c 100644 --- a/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt +++ b/app/src/oss/java/com/aryan/reader/BillingClientWrapper.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow data class ProUpgradeState( val productDetails: ProductDetailsEntity? = null, + val creditProducts: List = emptyList(), val hasValidPurchase: Boolean = false, val activePurchases: List = emptyList(), val billingClientReady: Boolean = false, @@ -34,9 +35,10 @@ class BillingClientWrapper( // No-op } - fun launchPurchaseFlow(activity: Activity) { + fun launchPurchaseFlow(activity: Activity, productId: String = PRO_LIFETIME_PRODUCT_ID) { _proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version") } + fun consumePurchase(purchaseToken: String) {} fun clearError() { _proUpgradeState.value = _proUpgradeState.value.copy(error = null) diff --git a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt b/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt index 0ad6bae..4d222f8 100644 --- a/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/CloudflareRepository.kt @@ -6,7 +6,8 @@ import kotlinx.serialization.Serializable @Serializable data class PurchaseVerificationRequest( val purchaseToken: String, - val idToken: String + val idToken: String, + val productId: String ) @Serializable @@ -16,7 +17,7 @@ data class VerificationResponse( ) class CloudflareRepository { - suspend fun verifyPurchase(purchaseToken: String): Result { + suspend fun verifyPurchase(purchaseToken: String, productId: String): Result { return Result.failure(Exception("Not available in OSS version")) } } \ No newline at end of file diff --git a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt index 717ed29..7a693d9 100644 --- a/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt +++ b/app/src/oss/java/com/aryan/reader/data/FirestoreRepository.kt @@ -80,9 +80,8 @@ class FirestoreRepository { // No-op } - fun listenToUserProfile(userId: String, onUpdate: (isPro: Boolean) -> Unit): Any? { - // In OSS, user is never Pro. Return null as the "listener" - onUpdate(false) + fun listenToUserProfile(userId: String, onUpdate: (isPro: Boolean, credits: Int) -> Unit): Any? { + onUpdate(false, 0) return null }