From 46620fa71a9f647a50f29417a8cf1140135390f9 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 18 Apr 2026 16:46:58 +0530 Subject: [PATCH] V1.0.43 oss (#202) * Added support for AI and Cloud credits in the Pro flavor. * Implemented credit-based authentication and authorization for AI features and Cloud TTS. * Updated AI feature access and purchase handling to a credit-based system. * Refactored and enhanced the Text-to-Speech (TTS) system with persistent caching and a redesigned UI. * Improved TTS cache management by organizing audio files by book title and adding a detailed cache storage UI. * Refactored the TTS service to use a WebSocket-based Gemini Live connection for cloud audio generation. * Removed the TTS cache settings tab and simplified voice sample playback by removing local caching logic. * Implemented a low-latency streaming mechanism for Cloud TTS using a custom `ConcurrentInputStream` and `ExoPlayer` data source. * Improved cloud TTS stability and prefetching logic in `TtsService` and `TtsPlaybackManager`. * Implemented AI summarization caching and cost tracking in the EPUB reader. * Enhanced chapter summary caching and UI feedback. * limit summaries for pro users to 10 per day * Implemented local caching for Cloud TTS audio chunks. * Removed the Free tier tab from `ProScreen` and simplified the subscription interface. Updated tab logic to focus on Pro and Credits, including a new cost breakdown section for AI and Cloud TTS features. * Refactored HTML parsing to include all child nodes during content chunking and semantic block parsing. * Improved image rendering consistency in epub pagination reader * Improved HTML parsing in `HtmlParser.kt` to better handle complex nested structures * Improved CSS styling support in the epub paginated reader for word spacing and text decorations. * Implemented scroll throttling in `epub_reader.js` to improve performance during scroll events * Improved CFI resolution and scrolling reliability in EPUB reader * Optimized PaginatedReader performance by caching text decorations. * Implemented batching for recent file database operations to handle large datasets and introduced `RecentFileSummary` to optimize data retrieval by excluding heavy JSON columns. * Improved navigation stability by wrapping `navController.navigate` and `popBackStack` calls in a try-catch block to handle `IllegalStateException` during concurrent transitions. Additionally, refined the backstack check for the main route to prevent redundant pops. * feat(tts): redesign TTS controls with overlay UI and cache management * Expanded and improved the TTS (Text-to-Speech) capabilities, particularly for Cloud voices. * Improved TTS playback control and cache management. * Integrated the TTS cache manager into the settings sheet and improved the TTS configuration UI. * Updated `DeviceVoicesTab` to respect the current TTS mode, disabling voice selection when not in `BASE` mode. * Improved error handling and state management for Cloud TTS in `TtsService` and `TtsPlaybackManager`. * Improved TTS voice selection UI and sample playback logic. * Updated `TtsUtils` and `TtsService` to remove `chunkIndex` from TTS cache filenames. Refined the cache file naming convention to rely on text and speaker hashes, and updated the cache file filter logic to correctly identify speakers in both legacy and new filename formats. * Optimized tile rendering and state propagation in PDF viewer * Added "Expand All", "Collapse All", and "Locate" functionality to the Table of Contents in both EPUB and PDF readers. * Added sign-in requirement for credit purchases and improved purchase migration logic. * Updated `EpubReaderTts` to support authenticated TTS requests by passing an auth token provider. The `ttsController.start` method now includes an `authToken` retrieved via `getAuthToken` and explicitly sets the `playbackSource` to "READER". * feat(ai): replace summarization popup with a comprehensive AI Hub Bottom Sheet * Improved locator logic and block traversal in `BookPaginator`. * Updated AI features and Cloud TTS logic. * Added manual clear and auto-reset functionality for AI summaries and recaps * Optimized file importing, EPUB parsing, and TTS playback concurrency. * Restricted TTS mode to BASE in OSS flavor and fixed TTS mode persistence in PDF viewer * Bump version to 1.0.43(44) --- app/build.gradle.kts | 4 +- app/src/main/assets/epub_reader.js | 100 +- .../java/com/aryan/reader/AppNavigation.kt | 37 +- app/src/main/java/com/aryan/reader/Common.kt | 1754 +++++++++-------- .../main/java/com/aryan/reader/HomeScreen.kt | 13 + .../java/com/aryan/reader/MainViewModel.kt | 97 +- .../main/java/com/aryan/reader/ProScreen.kt | 367 ++-- .../com/aryan/reader/data/RecentFileDao.kt | 8 +- .../com/aryan/reader/data/RecentFileEntity.kt | 25 + .../com/aryan/reader/data/RecentFileItem.kt | 29 + .../reader/data/RecentFilesRepository.kt | 76 +- .../java/com/aryan/reader/epub/EpubParser.kt | 13 +- .../aryan/reader/epub/SingleFileImporter.kt | 68 +- .../aryan/reader/epubreader/EpubReaderAi.kt | 112 +- .../reader/epubreader/EpubReaderContent.kt | 6 +- .../reader/epubreader/EpubReaderControls.kt | 498 +++-- .../reader/epubreader/EpubReaderDrawer.kt | 152 +- .../reader/epubreader/EpubReaderScreen.kt | 532 +++-- .../aryan/reader/epubreader/EpubReaderTts.kt | 15 +- .../reader/paginatedreader/BookPaginator.kt | 111 +- .../reader/paginatedreader/ContentStyler.kt | 48 +- .../aryan/reader/paginatedreader/CssParser.kt | 143 +- .../reader/paginatedreader/HtmlParser.kt | 101 +- .../aryan/reader/paginatedreader/Locator.kt | 24 +- .../reader/paginatedreader/PaginatedReader.kt | 436 +++- .../paginatedreader/PaginatedReaderData.kt | 12 +- .../aryan/reader/paginatedreader/Paginator.kt | 31 +- .../paginatedreader/data/BookCacheDatabase.kt | 2 +- .../paginatedreader/data/BookCacheEntities.kt | 2 +- .../com/aryan/reader/pdf/PdfPageComposable.kt | 16 +- .../com/aryan/reader/pdf/PdfVerticalReader.kt | 1 + .../com/aryan/reader/pdf/PdfViewerScreen.kt | 572 +++--- .../com/aryan/reader/tts/TtsController.kt | 43 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 407 +++- .../java/com/aryan/reader/tts/TtsService.kt | 624 +++++- .../java/com/aryan/reader/tts/TtsUtils.kt | 312 ++- app/src/main/res/values/strings.xml | 11 +- app/src/oss/java/com/aryan/reader/Auth.kt | 2 + .../com/aryan/reader/BillingClientWrapper.kt | 4 +- .../aryan/reader/data/CloudflareRepository.kt | 5 +- .../aryan/reader/data/FirestoreRepository.kt | 5 +- 41 files changed, 4412 insertions(+), 2406 deletions(-) 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 }