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)
This commit is contained in:
Aryan 2026-04-18 16:46:58 +05:30 committed by GitHub
parent e8f6be2800
commit 46620fa71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4412 additions and 2406 deletions

View file

@ -30,8 +30,8 @@ android {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 43 versionCode = 44
versionName = "1.0.42" versionName = "1.0.43"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild { externalNativeBuild {

View file

@ -581,25 +581,22 @@
var scrollHeight = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)); var scrollHeight = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight));
var clientHeight = Math.round(document.documentElement.clientHeight || window.innerHeight || 0); var clientHeight = Math.round(document.documentElement.clientHeight || window.innerHeight || 0);
if (clientHeight === 0) return; if (clientHeight === 0) return;
var activeFragment = null; var activeFragment = null;
var hasFoundAnyElementInDom = false; var hasFoundVisible = false;
if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) {
// Adjust threshold to be slightly more forgiving (padding + 60px)
var threshold = window.VIEWPORT_PADDING_TOP + 60; var threshold = window.VIEWPORT_PADDING_TOP + 60;
for (var i = 0; i < window.TOC_FRAGMENTS.length; i++) { for (var i = 0; i < window.TOC_FRAGMENTS.length; i++) {
var id = window.TOC_FRAGMENTS[i]; var id = window.TOC_FRAGMENTS[i];
// FIX: Look for both 'id' and 'name' attributes
var el = document.getElementById(id) || document.querySelector('[name="' + id + '"]'); var el = document.getElementById(id) || document.querySelector('[name="' + id + '"]');
if (el) { if (el) {
hasFoundVisible = true; hasFoundVisible = true;
var rect = el.getBoundingClientRect(); 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); console.log("FRAG_NAV_DEBUG: Checking #" + id + " | rect.top: " + Math.round(rect.top) + " | threshold: " + threshold);
if (rect.top <= threshold) { if (rect.top <= threshold) {
@ -660,8 +657,24 @@
); );
}; };
window.addEventListener("scroll", window.reportScrollState, { passive: true }); let scrollThrottleTimeout = null;
window.addEventListener("resize", window.reportScrollState); 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 () { window.triggerInitialScrollStateReport = function () {
var attempts = 0; var attempts = 0;
@ -896,12 +909,8 @@
} }
try { try {
console.log(`$ { console.log(`${TTS_HIGHLIGHT_LOG_TAG}: Resolving CFI to node...`);
TTS_HIGHLIGHT_LOG_TAG const location = window.getNodeAndOffsetFromCfi(cfi, true);
}
: Resolving CFI to node...`);
const location = window.getNodeAndOffsetFromCfi(cfi);
if (!location || !location.node) { if (!location || !location.node) {
const errorMsg = "JS: Could not find node for CFI."; 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; let currentNode = rootElement;
const steps = path.substring(1).split("/").map(Number); const steps = path.substring(1).split("/").map(Number);
@ -1420,12 +1429,26 @@
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`); let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
if (chunkElement) { if (chunkElement) {
if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) { if (chunkElement.innerHTML === "") {
console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution."); if (window.virtualization && window.virtualization.chunksData[chunkIndex]) {
chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex]; console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution.");
chunkElement.style.height = ""; chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex];
if (window.CURRENT_HIGHLIGHTS) { chunkElement.style.height = "";
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); 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; return currentNode;
} }
window.getNodeAndOffsetFromCfi = function (cfi) { window.getNodeAndOffsetFromCfi = function (cfi, requestChunkIfMissing = false) {
try { try {
var pathParts = cfi.split(":"); var pathParts = cfi.split(":");
var nodePath = pathParts[0]; var nodePath = pathParts[0];
@ -1470,7 +1493,7 @@
return { node: cfiRoot, offset: charOffset }; return { node: cfiRoot, offset: charOffset };
} }
let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve); let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve, requestChunkIfMissing);
if (!resolvedNode) return null; if (!resolvedNode) return null;
@ -1650,6 +1673,7 @@
} }
console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi); console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
window._requestedChunksForCfi = {}; // Reset the requested cache
if (!cleanCfi || !cleanCfi.startsWith('/')) { if (!cleanCfi || !cleanCfi.startsWith('/')) {
if (window.CfiBridge && window.CfiBridge.onScrollFinished) { if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
@ -1659,7 +1683,7 @@
} }
let attempts = 0; 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; let stabilizingFrames = 0;
const maxStabilizingFrames = 8; const maxStabilizingFrames = 8;
@ -1667,7 +1691,8 @@
attempts++; attempts++;
try { 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 (location && location.node) {
if (!document.body.contains(location.node)) { if (!document.body.contains(location.node)) {
@ -1700,9 +1725,24 @@
const range = document.createRange(); const range = document.createRange();
const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length); const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length);
range.setStart(currentNode, validOffset);
range.collapse(true); const endOffset = Math.min(validOffset + 1, currentNode.nodeValue.length);
const rect = range.getBoundingClientRect(); 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) { if (rect.top !== 0 || rect.bottom !== 0) {
targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5); targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5);
@ -1739,12 +1779,14 @@
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
setTimeout(attemptScroll, 100); setTimeout(attemptScroll, 100);
} else { } else {
console.log("PosSaveDiag: attemptScroll failed after " + maxAttempts + " attempts for CFI: " + cleanCfi);
if (window.CfiBridge && window.CfiBridge.onScrollFinished) { if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
window.CfiBridge.onScrollFinished(false); window.CfiBridge.onScrollFinished(false);
} }
} }
} }
} catch (e) { } catch (e) {
console.log("PosSaveDiag: attemptScroll exception: " + e.message);
if (window.CfiBridge && window.CfiBridge.onScrollFinished) { if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
window.CfiBridge.onScrollFinished(false); window.CfiBridge.onScrollFinished(false);
} }

View file

@ -72,30 +72,35 @@ fun AppNavigation(
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) { LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) { if (!uiState.isLoading) {
when (uiState.selectedFileType) { try {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { when (uiState.selectedFileType) {
if (uiState.selectedPdfUri != null) { FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { if (uiState.selectedPdfUri != null) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
popUpTo(AppDestinations.MAIN_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 -> {
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { if (uiState.selectedEpubBook != null) {
if (uiState.selectedEpubBook != null) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE) { popUpTo(AppDestinations.MAIN_ROUTE)
popUpTo(AppDestinations.MAIN_ROUTE) }
} }
} }
} }
} null -> {
null -> { val currentRoute = navController.currentBackStackEntry?.destination?.route
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) { if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false) navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
} }
} }
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation transition already in progress, ignoring.")
} }
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1039,6 +1039,19 @@ private fun AppDrawerContent(
uiState.currentUser.email?.let { email -> uiState.currentUser.email?.let { email ->
Text(text = email, style = MaterialTheme.typography.bodyMedium) 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 { } else {
// Signed-out: Show Sign In button at the top // Signed-out: Show Sign In button at the top

View file

@ -113,6 +113,7 @@ import java.util.UUID
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import kotlinx.coroutines.flow.distinctUntilChanged
private const val KEY_RENDER_MODE = "render_mode" private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
@ -214,6 +215,7 @@ data class ReaderScreenState(
val currentUser: UserData? = null, val currentUser: UserData? = null,
val isAuthMenuExpanded: Boolean = false, val isAuthMenuExpanded: Boolean = false,
val isProUser: Boolean = false, val isProUser: Boolean = false,
val credits: Int = 0,
val isSyncEnabled: Boolean = false, val isSyncEnabled: Boolean = false,
val isFolderSyncEnabled: Boolean = false, val isFolderSyncEnabled: Boolean = false,
val bannerMessage: BannerMessage? = null, val bannerMessage: BannerMessage? = null,
@ -269,7 +271,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val cloudflareRepository = CloudflareRepository() private val cloudflareRepository = CloudflareRepository()
private val remoteConfigRepository = RemoteConfigRepository() private val remoteConfigRepository = RemoteConfigRepository()
private var userProfileListener: Any? = null private var userProfileListener: Any? = null
private val migrationAttempted = MutableStateFlow(false)
private val _prefsUpdateFlow = MutableStateFlow(0L) private val _prefsUpdateFlow = MutableStateFlow(0L)
private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener private val prefsListener: SharedPreferences.OnSharedPreferenceChangeListener
private val feedbackRepository = FeedbackRepository(appContext) private val feedbackRepository = FeedbackRepository(appContext)
@ -800,9 +801,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(hasUnreadFeedback = hasUnread) } _internalState.update { it.copy(hasUnreadFeedback = hasUnread) }
} }
userProfileListener = userProfileListener = firestoreRepository.listenToUserProfile(newUserData.uid) { isProFromBackend, creditsFromBackend ->
firestoreRepository.listenToUserProfile(newUserData.uid) { isProFromBackend -> _internalState.update { it.copy(isProUser = isProFromBackend, credits = creditsFromBackend) }
_internalState.update { it.copy(isProUser = isProFromBackend) }
if (isProFromBackend) { if (isProFromBackend) {
verifyDeviceForProUser() verifyDeviceForProUser()
@ -823,13 +823,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
} }
triggerLegacyPurchaseMigration()
} }
} else { } 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 { private fun getDisplayPathFromUri(context: Context, uriString: String): String {
@ -1010,40 +1024,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
purchase: PurchaseEntity, isSilentMigrationCheck: Boolean = false purchase: PurchaseEntity, isSilentMigrationCheck: Boolean = false
) { ) {
viewModelScope.launch { 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.") Timber.e("Purchase verification failed: Incorrect product ID.")
if (!isSilentMigrationCheck) { if (!isSilentMigrationCheck) {
_internalState.update { _internalState.update { it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true)) }
it.copy(
bannerMessage = BannerMessage(appContext.getString(R.string.error_purchase_general), isError = true)
)
}
} }
billingClientWrapper.clearVerificationState() billingClientWrapper.clearVerificationState()
return@launch return@launch
} }
val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken) val result = cloudflareRepository.verifyPurchase(purchase.purchaseToken, productId)
if (result.isSuccess) { if (result.isSuccess) {
Timber.i("Backend verification successful. Firestore will update the app.") 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 { } else {
val exception = result.exceptionOrNull() val exception = result.exceptionOrNull()
if (exception?.message?.contains("already claimed") == true) { if (exception?.message?.contains("already claimed") == true) {
Timber.i( Timber.i("Migration/Refresh check: Purchase token is already claimed. Silently ignoring.")
"Migration check: Purchase token is already claimed by another account. Silently ignoring." if (productId.startsWith("credits_")) {
) billingClientWrapper.consumePurchase(purchase.purchaseToken)
}
} else { } else {
val errorMessage = appContext.getString(R.string.error_purchase_verification) val errorMessage = appContext.getString(R.string.error_purchase_verification)
Timber.e(exception, "Backend verification failed") Timber.e(exception, "Backend verification failed")
if (!isSilentMigrationCheck) { if (!isSilentMigrationCheck) {
_internalState.update { _internalState.update { it.copy(bannerMessage = BannerMessage(errorMessage, isError = true)) }
it.copy(bannerMessage = BannerMessage(errorMessage, isError = true))
}
} }
} }
} }
@ -2004,27 +2023,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun triggerLegacyPurchaseMigration() { private fun triggerLegacyPurchaseMigration() {
val user = _internalState.value.currentUser val user = _internalState.value.currentUser
val isProOnBackend = _internalState.value.isProUser
val localPurchases = billingClientWrapper.proUpgradeState.value.activePurchases val localPurchases = billingClientWrapper.proUpgradeState.value.activePurchases
val checkedUids = prefs.getStringSet(KEY_MIGRATION_CHECKED_UIDS, emptySet()) ?: emptySet() if (user != null && localPurchases.isNotEmpty()) {
if (user != null && user.uid in checkedUids) { Timber.i("Checking for unconsumed purchases or legacy pro statuses...")
Timber.d(
"Migration check for user ${user.uid} already performed on this device. Skipping."
)
return // Already checked, do nothing.
}
if (user != null && !isProOnBackend && localPurchases.isNotEmpty() && !migrationAttempted.value) { localPurchases.forEach { purchase ->
migrationAttempted.value = true verifyPurchaseWithBackend(purchase, isSilentMigrationCheck = 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) }
} }
} }
@ -2136,9 +2142,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} }
} }
fun launchPurchaseFlow(activity: android.app.Activity) { fun launchPurchaseFlow(activity: android.app.Activity, productId: String = BillingClientWrapper.PRO_LIFETIME_PRODUCT_ID) {
Timber.d("Attempting to launch purchase flow. Pro state is: ${proUpgradeState.value}") Timber.d("Attempting to launch purchase flow for $productId. Pro state is: ${proUpgradeState.value}")
billingClientWrapper.launchPurchaseFlow(activity) billingClientWrapper.launchPurchaseFlow(activity, productId)
} }
fun clearBillingError() { fun clearBillingError() {
@ -4485,6 +4491,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
_internalState.update { it.copy(useStrictFileFilter = enabled) } _internalState.update { it.copy(useStrictFileFilter = enabled) }
} }
suspend fun getAuthToken(): String? {
return authRepository.getIdToken()
}
companion object { companion object {
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
internal const val KEY_SHELVES = "shelf_names" 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_ADD_BOOKS_SOURCE = "add_books_source"
private const val KEY_SYNC_ENABLED = "sync_enabled" private const val KEY_SYNC_ENABLED = "sync_enabled"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" 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_INSTALLATION_ID = "installation_id"
private const val KEY_APP_OPEN_COUNT = "app_open_count" private const val KEY_APP_OPEN_COUNT = "app_open_count"
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri" internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"

View file

@ -61,10 +61,12 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aryan.reader.data.ProductDetailsEntity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.NumberFormat import java.text.NumberFormat
import java.util.Currency import java.util.Currency
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ProScreen( fun ProScreen(
@ -75,11 +77,12 @@ fun ProScreen(
val proUpgradeState by viewModel.proUpgradeState.collectAsState() val proUpgradeState by viewModel.proUpgradeState.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var showExistingPurchaseDialog by remember { mutableStateOf(false) } var showExistingPurchaseDialog by remember { mutableStateOf(false) }
var showEarlyAccessInfoDialog by remember { mutableStateOf(false) }
var showSignInRequiredDialog by remember { mutableStateOf(false) } var showSignInRequiredDialog by remember { mutableStateOf(false) }
val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 }) // Removed Free Tab, so tabCount is max 2
var selectedTabIndex by remember { mutableIntStateOf(1) } 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() val scope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
@ -92,8 +95,9 @@ fun ProScreen(
} }
} }
// Default to the Credits tab if they already own Pro
LaunchedEffect(uiState.isProUser) { LaunchedEffect(uiState.isProUser) {
if (uiState.isProUser) { if (uiState.isProUser && BuildConfig.FLAVOR == "pro") {
selectedTabIndex = 1 selectedTabIndex = 1
} }
} }
@ -109,10 +113,6 @@ fun ProScreen(
ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false }) ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false })
} }
if (showEarlyAccessInfoDialog) {
EarlyAccessInfoDialog(onDismiss = { showEarlyAccessInfoDialog = false })
}
if (showSignInRequiredDialog) { if (showSignInRequiredDialog) {
SignInRequiredDialog( SignInRequiredDialog(
onSignInClick = { onSignInClick = {
@ -130,7 +130,7 @@ fun ProScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { }, // Removed header content title = { },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -170,48 +170,23 @@ fun ProScreen(
.background( .background(
if (selectedTabIndex == 0) MaterialTheme.colorScheme.surface else Color.Transparent 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, width = if (selectedTabIndex == 0) 2.dp else 0.dp,
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent, color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape 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 = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
painter = painterResource(id = R.drawable.crown), painter = painterResource(id = R.drawable.crown),
contentDescription = "Pro", contentDescription = "Pro",
modifier = Modifier.size(16.dp), 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)) Spacer(modifier = Modifier.width(4.dp))
AutoSizeText(stringResource(R.string.drawer_pro_unlocked), AutoSizeText(stringResource(R.string.drawer_pro_unlocked),
style = LocalTextStyle.current.copy( 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 fontWeight = FontWeight.SemiBold
) )
) )
@ -220,6 +195,31 @@ fun ProScreen(
selectedContentColor = MaterialTheme.colorScheme.primary, selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant 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)) Spacer(modifier = Modifier.height(16.dp))
@ -229,101 +229,40 @@ fun ProScreen(
modifier = Modifier.fillMaxWidth().fillMaxHeight(), modifier = Modifier.fillMaxWidth().fillMaxHeight(),
userScrollEnabled = true userScrollEnabled = true
) { page -> ) { page ->
if (page == 0) { when (page) {
FreeTierCard() 0 -> {
} else { ProTierCard(
ProTierCard( isProUser = uiState.isProUser,
isProUser = uiState.isProUser, isUserSignedIn = uiState.currentUser != null,
isUserSignedIn = uiState.currentUser != null, proUpgradeState = proUpgradeState,
proUpgradeState = proUpgradeState, onUpgradeClick = {
onUpgradeClick = { (context as? Activity)?.let {
(context as? Activity)?.let { viewModel.launchPurchaseFlow(it)
viewModel.launchPurchaseFlow(it) }
} },
}, onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true },
onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true }, onSignInRequiredClick = { showSignInRequiredDialog = true })
onShowEarlyAccessInfo = { showEarlyAccessInfoDialog = 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 @Composable
private fun ProTierCard( private fun ProTierCard(
isProUser: Boolean, isProUser: Boolean,
@ -331,7 +270,6 @@ private fun ProTierCard(
proUpgradeState: ProUpgradeState, proUpgradeState: ProUpgradeState,
onUpgradeClick: () -> Unit, onUpgradeClick: () -> Unit,
onShowExistingPurchaseDialog: () -> Unit, onShowExistingPurchaseDialog: () -> Unit,
onShowEarlyAccessInfo: () -> Unit,
onSignInRequiredClick: () -> Unit onSignInRequiredClick: () -> Unit
) { ) {
val productDetails = proUpgradeState.productDetails val productDetails = proUpgradeState.productDetails
@ -431,27 +369,6 @@ private fun ProTierCard(
} }
} }
Spacer(modifier = Modifier.height(16.dp)) 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 @Composable
fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) { fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
@ -705,4 +609,145 @@ fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_not_now)) } TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_not_now)) }
} }
) )
}
@Composable
private fun CreditTierCard(
credits: Int,
creditProducts: List<ProductDetailsEntity>,
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
)
}
}
} }

View file

@ -34,8 +34,8 @@ interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>) suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") @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<List<RecentFileEntity>> fun getRecentFiles(): Flow<List<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileEntity> suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileEntity>
@ -46,8 +46,8 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") @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<RecentFileEntity> fun getRecentFilesList(limit: Int): List<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
suspend fun deleteFilePermanently(bookIds: List<String>) suspend fun deleteFilePermanently(bookIds: List<String>)

View file

@ -52,4 +52,29 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(defaultValue = "NULL") val highlights: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long @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
) )

View file

@ -157,4 +157,33 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
customName = this.customName, customName = this.customName,
highlightsJson = this.highlightsJson 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
)
} }

View file

@ -360,51 +360,57 @@ class RecentFilesRepository(private val context: Context) {
} }
suspend fun markAsNotRecent(bookIds: List<String>) = withContext(Dispatchers.IO) { suspend fun markAsNotRecent(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) { bookIds.chunked(900).forEach { chunk ->
Timber.d("DeleteDebug: DAO - Marking ${bookIds.size} items as not recent.") if (chunk.isNotEmpty()) {
recentFileDao.markAsNotRecent(bookIds, System.currentTimeMillis()) Timber.d("DeleteDebug: DAO - Marking ${chunk.size} items as not recent.")
recentFileDao.markAsNotRecent(chunk, System.currentTimeMillis())
}
} }
} }
suspend fun markAsDeleted(bookIds: List<String>) = withContext(Dispatchers.IO) { suspend fun markAsDeleted(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) { bookIds.chunked(900).forEach { chunk ->
recentFileDao.markAsDeleted(bookIds, System.currentTimeMillis()) if (chunk.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Marked ${bookIds.size} items as deleted.") recentFileDao.markAsDeleted(chunk, System.currentTimeMillis())
Timber.d("DeleteDebug: DAO - Marked ${chunk.size} items as deleted.")
}
} }
} }
suspend fun deleteFilePermanently(bookIds: List<String>) = withContext(Dispatchers.IO) { suspend fun deleteFilePermanently(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isEmpty()) return@withContext 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()) { if (itemsToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.") Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
itemsToRemove.forEach { item -> itemsToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) } item.coverImagePath?.let { deleteCachedCover(it) }
try { try {
item.uriString?.let { bookImporter.deleteBookByUriString(it) } item.uriString?.let { bookImporter.deleteBookByUriString(it) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}") Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}")
} }
try { try {
pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete() pdfAnnotationRepository.getAnnotationFileForSync(item.bookId)?.delete()
pdfRichTextRepository.getFileForSync(item.bookId).delete() pdfRichTextRepository.getFileForSync(item.bookId).delete()
pageLayoutRepository.getLayoutFile(item.bookId).delete() pageLayoutRepository.getLayoutFile(item.bookId).delete()
pdfTextBoxRepository.getFileForSync(item.bookId).delete() pdfTextBoxRepository.getFileForSync(item.bookId).delete()
pdfHighlightRepository.getFileForSync(item.bookId).delete() pdfHighlightRepository.getFileForSync(item.bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}") val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}")
if (cacheDir.exists()) cacheDir.deleteRecursively() if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}") 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<RecentFileItem>) = withContext(Dispatchers.IO) { suspend fun addRecentFiles(items: List<RecentFileItem>) = withContext(Dispatchers.IO) {
if (items.isEmpty()) return@withContext if (items.isEmpty()) return@withContext
val entities = items.map { it.toRecentFileEntity() } items.chunked(900).forEach { chunk ->
recentFileDao.insertOrUpdateFiles(entities) val entities = chunk.map { it.toRecentFileEntity() }
recentFileDao.insertOrUpdateFiles(entities)
}
Timber.d("Batch inserted/updated ${items.size} recent files in DB.") Timber.d("Batch inserted/updated ${items.size} recent files in DB.")
} }
} }

View file

@ -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) filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
} }
} }
@ -309,7 +311,7 @@ class EpubParser(private val context: Context) {
} }
val chaptersFromSpine = if (parseContent) { val chaptersFromSpine = if (parseContent) {
parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap) parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap, extractionRoot)
} else { } else {
emptyList() emptyList()
} }
@ -476,7 +478,8 @@ class EpubParser(private val context: Context) {
spine: Node, spine: Node,
manifestItems: Map<String, EpubManifestItem>, manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>, filesContentMap: Map<String, EpubFile>,
ncxMetadataMap: Map<String, NcxMetadata> ncxMetadataMap: Map<String, NcxMetadata>,
extractionRoot: File
): List<EpubChapter> = withContext(Dispatchers.Default) { ): List<EpubChapter> = withContext(Dispatchers.Default) {
val parsingSemaphore = Semaphore(6) val parsingSemaphore = Semaphore(6)
@ -489,7 +492,9 @@ class EpubParser(private val context: Context) {
val idRef = itemRef.getAttribute("idref") val idRef = itemRef.getAttribute("idref")
val item = manifestItems[idRef] ?: return@withPermit null 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 mediaType = item.mediaType
val absPath = item.absPath val absPath = item.absPath

View file

@ -239,18 +239,7 @@ class SingleFileImporter(private val context: Context) {
val fileName = "page_$pageNum.html" val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
val fullHtml = """ val fullHtml = "<!DOCTYPE html>\n<html>\n<head>\n<title>$chapterTitle</title>\n<style>$style</style>\n</head>\n<body>\n$htmlBody\n</body>\n</html>"
<!DOCTYPE html>
<html>
<head>
<title>$chapterTitle</title>
<style>$style</style>
</head>
<body>
$htmlBody
</body>
</html>
""".trimIndent()
file.writeText(fullHtml) file.writeText(fullHtml)
@ -355,18 +344,7 @@ class SingleFileImporter(private val context: Context) {
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
val chapterTitle = "Part $chapterCounter" val chapterTitle = "Part $chapterCounter"
val fullHtml = """ val fullHtml = "<!DOCTYPE html>\n<html>\n<head>\n<title>$chapterTitle</title>\n<style>$cssStyle</style>\n</head>\n<body>\n$currentChapterContent\n</body>\n</html>"
<!DOCTYPE html>
<html>
<head>
<title>$chapterTitle</title>
<style>$cssStyle</style>
</head>
<body>
$currentChapterContent
</body>
</html>
""".trimIndent()
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) } 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") Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
val fullHtml = """ val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html")
<!DOCTYPE html> try {
<html> FileOutputStream(tempFile).bufferedWriter().use { writer ->
<head> val title = originalBookNameHint.substringBeforeLast(".")
<title>${originalBookNameHint.substringBeforeLast(".")}</title> writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n")
</head> writer.write(htmlContent)
<body> writer.write("\n</body>\n</html>")
$htmlContent }
</body>
</html>
""".trimIndent()
// 4. Delegate to the already built HTML caching and chunking mechanisms! tempFile.inputStream().use { tempStream ->
return@withContext parseHtml(fullHtml.byteInputStream(), originalBookNameHint, bookId, parseContent) return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent)
}
} finally {
if (tempFile.exists()) {
tempFile.delete()
}
}
} }
private fun writeHtmlChapter( private fun writeHtmlChapter(
@ -720,18 +701,7 @@ class SingleFileImporter(private val context: Context) {
val fileName = "page_$pageNum.html" val fileName = "page_$pageNum.html"
val file = File(extractionDir, fileName) val file = File(extractionDir, fileName)
val fullHtml = """ val fullHtml = "<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<title>${title.replace("\"", "&quot;")}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${bodyContent.trim()}\n</body>\n</html>"
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>${cssStyle}</style>
</head>
<body>
${bodyContent.trim()}
</body>
</html>
""".trimIndent()
file.writeText(fullHtml) file.writeText(fullHtml)

View file

@ -33,8 +33,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.epub.EpubBook import com.aryan.reader.epub.EpubBook
@ -55,6 +55,8 @@ import java.net.URL
*/ */
suspend fun summarizeBookContent( suspend fun summarizeBookContent(
content: String, content: String,
authToken: String?,
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
onUpdate: (String) -> Unit, onUpdate: (String) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
onFinish: () -> Unit onFinish: () -> Unit
@ -74,6 +76,9 @@ suspend fun summarizeBookContent(
connection.requestMethod = "POST" connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8") connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("Accept", "application/json")
if (authToken != null) {
connection.setRequestProperty("Authorization", "Bearer $authToken")
}
connection.connectTimeout = 15000 connection.connectTimeout = 15000
connection.readTimeout = 120000 connection.readTimeout = 120000
connection.doOutput = true connection.doOutput = true
@ -88,16 +93,29 @@ suspend fun summarizeBookContent(
} }
val responseCode = connection.responseCode 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) { if (responseCode == HttpURLConnection.HTTP_OK) {
var hasReceivedData = false var hasReceivedData = false
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
Timber.d("Summarization: Received line: $line")
try { try {
val jsonResponse = JSONObject(line!!) 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 { jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
onUpdate(it) onUpdate(it)
hasReceivedData = true hasReceivedData = true
@ -137,6 +155,7 @@ suspend fun summarizeBookContent(
* Fetches past summaries from cache/network and combines with current context. * Fetches past summaries from cache/network and combines with current context.
*/ */
suspend fun executeRecapLogic( suspend fun executeRecapLogic(
authToken: String?,
epubBook: EpubBook, epubBook: EpubBook,
chapterIndex: Int, chapterIndex: Int,
characterLimit: Int, characterLimit: Int,
@ -145,6 +164,7 @@ suspend fun executeRecapLogic(
context: Context, context: Context,
onProgressUpdate: (String) -> Unit, onProgressUpdate: (String) -> Unit,
onResultUpdate: (String) -> Unit, onResultUpdate: (String) -> Unit,
onCostReceived: (Double?) -> Unit = {},
onError: (String) -> Unit, onError: (String) -> Unit,
onFinish: () -> Unit onFinish: () -> Unit
) { ) {
@ -176,18 +196,38 @@ suspend fun executeRecapLogic(
summarizeBookContent( summarizeBookContent(
content = textToSummarize, content = textToSummarize,
authToken = authToken,
onUsageReceived = { cost, _ ->
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
},
onUpdate = { sb.append(it) }, onUpdate = { sb.append(it) },
onError = { onError = {
Timber.e("Failed to summarize Ch $i for recap: $it") Timber.e("Failed to summarize Ch $i for recap: $it")
latch.complete(false) 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() val success = latch.await()
if (success && sb.isNotEmpty()) { if (success && sb.isNotEmpty()) {
val summary = sb.toString() 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) pastSummaries.add(summary)
} }
} }
@ -223,7 +263,9 @@ suspend fun executeRecapLogic(
pastSummaries = pastSummaries, pastSummaries = pastSummaries,
currentText = finalContextText, currentText = finalContextText,
context = context, context = context,
authToken = authToken,
onUpdate = { chunk -> onResultUpdate(chunk) }, onUpdate = { chunk -> onResultUpdate(chunk) },
onCostReceived = onCostReceived,
onError = { error -> onError(error) }, onError = { error -> onError(error) },
onFinish = { onFinish() } onFinish = { onFinish() }
) )
@ -234,16 +276,22 @@ suspend fun executeRecapLogic(
*/ */
@Composable @Composable
fun EpubReaderAiOverlays( fun EpubReaderAiOverlays(
showSummarizationPopup: Boolean, bookTitle: String,
currentChapterIndex: Int,
chapterTitle: String,
summaryCacheManager: SummaryCacheManager,
showAiHubSheet: Boolean,
summarizationResult: SummarizationResult?, summarizationResult: SummarizationResult?,
isSummarizationLoading: Boolean, isSummarizationLoading: Boolean,
onDismissSummarization: () -> Unit, onGenerateSummary: (Boolean) -> Unit,
showSummarizationUpsellDialog: Boolean,
onDismissSummarizationUpsell: () -> Unit,
showRecapPopup: Boolean,
recapResult: SummarizationResult?, recapResult: SummarizationResult?,
isRecapLoading: Boolean, isRecapLoading: Boolean,
onDismissRecap: () -> Unit, onGenerateRecap: () -> Unit,
onDismissAiHub: () -> Unit,
onClearSummary: () -> Unit = {},
onClearRecap: () -> Unit = {},
showSummarizationUpsellDialog: Boolean,
onDismissSummarizationUpsell: () -> Unit,
showAiDefinitionPopup: Boolean, showAiDefinitionPopup: Boolean,
selectedTextForAi: String?, selectedTextForAi: String?,
aiDefinitionResult: AiDefinitionResult?, aiDefinitionResult: AiDefinitionResult?,
@ -253,25 +301,30 @@ fun EpubReaderAiOverlays(
onDismissDictionaryUpsell: () -> Unit, onDismissDictionaryUpsell: () -> Unit,
onNavigateToPro: () -> Unit, onNavigateToPro: () -> Unit,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
onOpenExternalDictionary: (String) -> Unit onOpenExternalDictionary: (String) -> Unit,
getAuthToken: suspend () -> String?,
credits: Int,
isProUser: Boolean
) { ) {
if (showSummarizationPopup) { if (showAiHubSheet) {
SummarizationPopup( AiHubBottomSheet(
title = stringResource(R.string.ai_chapter_summary), bookTitle = bookTitle,
result = summarizationResult, currentChapterIndex = currentChapterIndex,
isLoading = isSummarizationLoading, chapterTitle = chapterTitle,
onDismiss = onDismissSummarization, summaryCacheManager = summaryCacheManager,
isMainTtsActive = isTtsSessionActive summarizationResult = summarizationResult,
) isSummarizationLoading = isSummarizationLoading,
} onGenerateSummary = onGenerateSummary,
recapResult = recapResult,
if (showRecapPopup) { isRecapLoading = isRecapLoading,
SummarizationPopup( onGenerateRecap = onGenerateRecap,
title = stringResource(R.string.ai_story_recap_beta), onDismiss = onDismissAiHub,
result = recapResult, onClearSummary = onClearSummary,
isLoading = isRecapLoading, onClearRecap = onClearRecap,
onDismiss = onDismissRecap,
isMainTtsActive = isTtsSessionActive, isMainTtsActive = isTtsSessionActive,
getAuthToken = getAuthToken,
credits = credits,
isProUser = isProUser
) )
} }
@ -312,7 +365,8 @@ fun EpubReaderAiOverlays(
isMainTtsActive = isTtsSessionActive, isMainTtsActive = isTtsSessionActive,
onOpenExternalDictionary = { onOpenExternalDictionary = {
selectedTextForAi?.let { text -> onOpenExternalDictionary(text) } selectedTextForAi?.let { text -> onOpenExternalDictionary(text) }
} },
getAuthToken = getAuthToken
) )
} }

View file

@ -62,9 +62,9 @@ suspend fun loadChapterContent(
val (headContent, chunks) = if (htmlFile.exists()) { val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8") val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html() val head = doc.head().html()
val bodyChildren = doc.body().children().toList() val bodyNodes = doc.body().childNodes().toList()
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements -> val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() } chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }
} }
if (chunkedList.isEmpty()) { if (chunkedList.isEmpty()) {
head to listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>") head to listOf("<body><p>${context.getString(R.string.chapter_empty)}</p></body>")

View file

@ -24,7 +24,6 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.os.Build import android.os.Build
import android.speech.tts.TextToSpeech
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent 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.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -101,14 +98,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -140,6 +134,7 @@ import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import com.aryan.reader.tts.TtsPlaybackManager.TtsState import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -188,7 +183,6 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation: (Boolean) -> Unit, onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit, onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit, onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit, onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit, onOpenVisualOptions: () -> Unit,
@ -468,9 +462,10 @@ fun EpubReaderTopBar(
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) { if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive,
onClick = { onClick = {
showMoreMenu = false showMoreMenu = false
onOpenDeviceVoiceSettings() onOpenTtsSettings()
}, },
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -478,24 +473,8 @@ fun EpubReaderTopBar(
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) 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, ttsState: TtsState,
isProUser: Boolean, isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
onOpenTtsControls: () -> Unit,
onOpenSlider: () -> Unit, onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit, onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
onSummarize: () -> Unit, onOpenAiHub: () -> Unit,
onRecap: () -> Unit,
onToggleTts: () -> Unit, onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit,
hiddenTools: Set<String>, hiddenTools: Set<String>,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -600,90 +576,36 @@ fun EpubReaderBottomBar(
"KotlinConstantConditions", "KotlinConstantConditions",
"SimplifyBooleanWithConstants" "SimplifyBooleanWithConstants"
) if (BuildConfig.FLAVOR != "oss") { ) if (BuildConfig.FLAVOR != "oss") {
Box { TooltipIconButton(
var showAiFeaturesMenu by remember { mutableStateOf(false) } text = stringResource(R.string.tooltip_ai),
TooltipIconButton( description = stringResource(R.string.tooltip_ai_desc),
text = stringResource(R.string.tooltip_ai), onClick = onOpenAiHub
description = stringResource(R.string.tooltip_ai_desc), ) {
onClick = { showAiFeaturesMenu = true }) { Icon(
Icon( painter = painterResource(id = R.drawable.ai),
painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features"
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()
})
}
}
} }
} }
} }
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) { if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
Box { TooltipIconButton(
Row(verticalAlignment = Alignment.CenterVertically) { text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
TooltipIconButton( else stringResource(R.string.tooltip_tts_start),
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start), else stringResource(R.string.tooltip_tts_start_desc),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) onClick = onToggleTts
else stringResource(R.string.tooltip_tts_start_desc), ) {
onClick = onToggleTts Icon(
) { painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
Icon( id = R.drawable.text_to_speech
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
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
) )
)
}
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"
)
}
}
}
}
} }
} }
} }
@ -1450,194 +1372,220 @@ fun CustomizeToolsSheet(
@androidx.annotation.OptIn(UnstableApi::class) @androidx.annotation.OptIn(UnstableApi::class)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TtsControlsSheet( fun TtsOverlayControls(
onDismiss: () -> Unit, ttsController: com.aryan.reader.tts.TtsController,
onOpenDeviceVoiceSettings: () -> Unit, ttsState: TtsState,
ttsController: com.aryan.reader.tts.TtsController 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 context = androidx.compose.ui.platform.LocalContext.current
val ttsState by ttsController.ttsState.collectAsState()
// Local TTS for Sample Playback
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
var isTtsReady by remember { mutableStateOf(false) }
var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) } var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) } var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
var isDraggingRate by remember { mutableStateOf(false) } var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) } var isDraggingPitch by remember { mutableStateOf(false) }
// Initialize Local TTS for samples val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
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 saveAndSlice = { val saveAndApply = {
saveTtsSpeechRate(context, rate) saveTtsSpeechRate(context, rate)
saveTtsPitch(context, pitch) 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( Surface(
onDismissRequest = onDismiss, shape = RoundedCornerShape(28.dp),
contentWindowInsets = { WindowInsets.navigationBars } 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)) { AnimatedContent(
Text(stringResource(R.string.tts_voice_adjustments), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) targetState = isCollapsed,
Spacer(Modifier.height(16.dp)) transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
// Rate Slider ) { collapsed ->
Row(verticalAlignment = Alignment.CenterVertically) { if (collapsed) {
Text(stringResource(R.string.tts_speed_label, "%.1f".format(rate)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) Row(
IconButton(onClick = { modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
rate = 1.0f verticalAlignment = Alignment.CenterVertically,
ttsController.pause() horizontalArrangement = Arrangement.spacedBy(4.dp)
saveAndSlice() ) {
}) { IconButton(
Icon(Icons.Default.Refresh, contentDescription = "Reset Speed") onClick = { onCollapseChange(false) },
} modifier = Modifier.size(36.dp)
}
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
)
) { ) {
if (ttsState.isLoading) { Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
CircularProgressIndicator( }
modifier = Modifier.size(32.dp), Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
color = MaterialTheme.colorScheme.onPrimaryContainer, FilledIconButton(
strokeWidth = 3.dp 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( Icon(
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play), 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), "Play/Pause",
modifier = Modifier.size(32.dp) 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)) Surface(
OutlinedButton( color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
onClick = { shape = RoundedCornerShape(8.dp)
onDismiss() ) {
onOpenDeviceVoiceSettings() val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
}, GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
modifier = Modifier.fillMaxWidth() } else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
) {
Icon(Icons.Default.Settings, contentDescription = null) Text(
Spacer(Modifier.width(8.dp)) voiceName,
Text(stringResource(R.string.tts_system_settings)) 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))
}
}
}
}
}
} }
} }
} }

View file

@ -354,63 +354,113 @@ private fun ChaptersList(
result result
} }
Box(modifier = Modifier.fillMaxSize()) { val coroutineScope = rememberCoroutineScope()
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)
// HIGHLIGHT LOGIC FIXED val activeTocEntry = remember(effectiveToc, currentChapterPath, activeFragmentId, firstEntryForCurrentChapter) {
val isCurrentPath = currentChapterPath == entry.absolutePath effectiveToc.find {
val matchesFragment = entry.fragmentId == activeFragmentId it.absolutePath == currentChapterPath && it.fragmentId == activeFragmentId
} ?: firstEntryForCurrentChapter
}
// Fallback logic val onScrollToCurrent = {
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter coroutineScope.launch {
val isHighlighting = isCurrentPath && (matchesFragment || isFallback) val targetEntry = activeTocEntry ?: return@launch
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
if (isCurrentPath) { if (targetOriginalIndex != -1) {
Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback") // Ensure parents are expanded
} var currentLevel = targetEntry.depth
val newExpanded = expandedEntryIndices.toMutableSet()
if (isCurrentPath) { for (i in targetOriginalIndex downTo 0) {
Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting") val entry = effectiveToc[i]
} if (entry.depth < currentLevel) {
newExpanded.add(i)
TocTreeItem( currentLevel = entry.depth
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)
}
} }
) }
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( HorizontalDivider()
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd) 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)
)
}
} }
} }

View file

@ -27,6 +27,8 @@ package com.aryan.reader.epubreader
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap 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.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.BuildConfig
import com.aryan.reader.BuiltInThemes import com.aryan.reader.BuiltInThemes
import com.aryan.reader.CustomTopBanner import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.ReaderThemePanel import com.aryan.reader.ReaderThemePanel
@ -180,6 +182,7 @@ import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveReaderThemeId import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
@ -416,6 +419,7 @@ fun EpubReaderScreen(
initialBookmarksJson = initialBookmarksJson, initialBookmarksJson = initialBookmarksJson,
initialHighlightsJson = uiState.initialHighlightsJson, initialHighlightsJson = uiState.initialHighlightsJson,
isProUser = isProUser, isProUser = isProUser,
credits = uiState.credits,
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onSavePosition = onSavePosition, onSavePosition = onSavePosition,
onBookmarksChanged = onBookmarksChanged, onBookmarksChanged = onBookmarksChanged,
@ -438,7 +442,8 @@ fun EpubReaderScreen(
} }
} }
} }
} else null } else null,
viewModel = viewModel
) )
} }
@ -456,6 +461,7 @@ fun EpubReaderHost(
initialBookmarksJson: String?, initialBookmarksJson: String?,
initialHighlightsJson: String?, initialHighlightsJson: String?,
isProUser: Boolean, isProUser: Boolean,
credits: Int,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -466,7 +472,8 @@ fun EpubReaderHost(
customFonts: List<CustomFontEntity>, customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit, onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null, onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null onDeleteReflow: (() -> Unit)? = null,
viewModel: MainViewModel
) { ) {
val view = LocalView.current val view = LocalView.current
val context = LocalContext.current val context = LocalContext.current
@ -479,6 +486,7 @@ fun EpubReaderHost(
val containerFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToPosition by remember { mutableStateOf(false) } var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) } var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
@ -516,7 +524,13 @@ fun EpubReaderHost(
mutableStateOf(loadPageTurnAnimationSetting(context)) 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) { val locatorConverter = remember(context) {
LocatorConverter( LocatorConverter(
@ -546,6 +560,7 @@ fun EpubReaderHost(
} }
var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) { val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title) if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
@ -679,24 +694,35 @@ fun EpubReaderHost(
if (effectiveUseOnline) { if (effectiveUseOnline) {
val wordCount = countWords(word) val wordCount = countWords(word)
if (isProUser || wordCount <= 1) { if (wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = word selectedTextForAi = word
showAiDefinitionPopup = true showAiDefinitionPopup = true
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
isAiDefinitionLoading = true isAiDefinitionLoading = true
aiDefinitionResult = null aiDefinitionResult = null
fetchAiDefinition( fetchAiDefinition(
text = word, onUpdate = { chunk -> text = word,
val currentDefinition = aiDefinitionResult?.definition ?: "" onUpdate = { chunk ->
aiDefinitionResult = val currentDefinition = aiDefinitionResult?.definition ?: ""
AiDefinitionResult(definition = currentDefinition + chunk) aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
}, onError = { error -> },
aiDefinitionResult = AiDefinitionResult(error = error) authToken = token,
}, onFinish = { isAiDefinitionLoading = false }, context = context 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 { } else {
if (!selectedDictPackage.isNullOrEmpty()) { if (!selectedDictPackage.isNullOrEmpty()) {
@ -728,10 +754,6 @@ fun EpubReaderHost(
val summaryCacheManager = remember(context) { SummaryCacheManager(context) } val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) } var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(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 currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) } var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
@ -796,10 +818,15 @@ fun EpubReaderHost(
var webViewRefForTts by remember { mutableStateOf<WebView?>(null) } var webViewRefForTts by remember { mutableStateOf<WebView?>(null) }
var showSummarizationPopup by remember { mutableStateOf(false) } var showAiHubSheet by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) } var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isSummarizationLoading by remember { mutableStateOf(false) } var isSummarizationLoading by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(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 epubSearcher = remember(epubBook) { createEpubSearcher(epubBook) }
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@ -964,7 +991,12 @@ fun EpubReaderHost(
LaunchedEffect(ttsState.errorMessage) { LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message -> 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 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 isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) } var isAutoScrollPlaying by remember { mutableStateOf(false) }
@ -1041,7 +1075,6 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) } var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) } var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) } var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) } var showPaletteManager by remember { mutableStateOf(false) }
@ -1102,6 +1135,11 @@ fun EpubReaderHost(
} }
fun startTts() { fun startTts() {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
if (isAutoScrollModeActive) { if (isAutoScrollModeActive) {
isAutoScrollModeActive = false isAutoScrollModeActive = false
isAutoScrollPlaying = false isAutoScrollPlaying = false
@ -1114,6 +1152,7 @@ fun EpubReaderHost(
webView = webViewRefForTts, webView = webViewRefForTts,
onPaginatedStart = { onPaginatedStart = {
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
val currentPage = paginatedPagerState.currentPage val currentPage = paginatedPagerState.currentPage
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage) val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage)
@ -1136,7 +1175,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER",
authToken = token
) )
} }
} }
@ -1153,8 +1193,14 @@ fun EpubReaderHost(
) )
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) { fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
val action = { val action = {
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
val bookPaginator = paginator as? BookPaginator val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode ?: return@launch val chapterIndex = currentChapterInPaginatedMode ?: return@launch
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
@ -1191,7 +1237,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER",
authToken = token
) )
} }
} }
@ -1237,7 +1284,8 @@ fun EpubReaderHost(
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart }, onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts, userStoppedTts = userStoppedTts,
scope = scope, scope = scope,
currentTtsMode = currentTtsMode currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() }
) )
TtsHighlightHandler( TtsHighlightHandler(
@ -1338,12 +1386,15 @@ fun EpubReaderHost(
} }
val runRecap = { chapterIdx: Int, charLimit: Int -> val runRecap = { chapterIdx: Int, charLimit: Int ->
showRecapPopup = true showAiHubSheet = true
isRecapLoading = true isRecapLoading = true
recapResult = null recapResult = null
recapProgressMessage = "Checking past chapters..." recapProgressMessage = "Checking past chapters..."
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
var currentCost: Double? = null
executeRecapLogic( executeRecapLogic(
epubBook = epubBook, epubBook = epubBook,
chapterIndex = chapterIdx, chapterIndex = chapterIdx,
@ -1352,13 +1403,27 @@ fun EpubReaderHost(
paginator = paginator, paginator = paginator,
context = context, context = context,
onProgressUpdate = { recapProgressMessage = it }, onProgressUpdate = { recapProgressMessage = it },
onCostReceived = { cost ->
currentCost = cost
recapResult = recapResult?.copy(cost = cost) ?: SummarizationResult(cost = cost)
},
onResultUpdate = { chunk -> onResultUpdate = { chunk ->
isRecapLoading = false isRecapLoading = false
val current = recapResult?.summary ?: "" val current = recapResult?.summary ?: ""
recapResult = SummarizationResult(summary = current + chunk) recapResult = SummarizationResult(
summary = current + chunk,
cost = currentCost
)
}, },
authToken = token,
onError = { error -> onError = { error ->
recapResult = SummarizationResult(error = error) if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showRecapPopup = false
isRecapLoading = false
} else {
recapResult = SummarizationResult(error = error)
}
}, },
onFinish = { isRecapLoading = false } 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( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
@ -2552,6 +2772,7 @@ fun EpubReaderHost(
ttsScope = scope, ttsScope = scope,
onTtsTextReady = { jsonString -> onTtsTextReady = { jsonString ->
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this
val ttsChunks = mutableListOf<TtsChunk>() val ttsChunks = mutableListOf<TtsChunk>()
try { try {
@ -2587,9 +2808,14 @@ fun EpubReaderHost(
Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}") Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}")
if (ttsChunks.isNotEmpty()) { if (ttsChunks.isNotEmpty()) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false
return@launch
}
ttsShouldStartOnChapterLoad = false ttsShouldStartOnChapterLoad = false
val chapterTitle = val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title
chapters.getOrNull(currentChapterIndex)?.title
val coverUriString = coverImagePath?.let { val coverUriString = coverImagePath?.let {
Uri.fromFile(File(it)).toString() Uri.fromFile(File(it)).toString()
} }
@ -2600,7 +2826,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER",
authToken = token
) )
} else { } else {
Timber.w("No TTS chunks were created from JSON, not starting TTS." Timber.w("No TTS chunks were created from JSON, not starting TTS."
@ -2625,25 +2852,48 @@ fun EpubReaderHost(
onContentReadyForSummarization = { content -> onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization") Timber.d("Content received for summarization")
scope.launch { scope.launch {
val token = viewModel.getAuthToken()
val chapterIndexToSave = currentChapterIndex val chapterIndexToSave = currentChapterIndex
val bookTitleToSave = epubBook.title val bookTitleToSave = epubBook.title
val finalSummaryBuilder = StringBuilder() val finalSummaryBuilder = StringBuilder()
var currentCost: Double? = null
var currentFreeRemaining: Int? = null
summarizeBookContent( summarizeBookContent(
content = content, 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 -> onUpdate = { chunk ->
finalSummaryBuilder.append(chunk) finalSummaryBuilder.append(chunk)
val currentSummary = summarizationResult?.summary ?: "" val currentSummary = summarizationResult?.summary ?: ""
summarizationResult = SummarizationResult(summary = currentSummary + chunk) summarizationResult = SummarizationResult(
summary = currentSummary + chunk,
cost = currentCost,
freeRemaining = currentFreeRemaining
)
}, },
onError = { error -> onError = { error ->
summarizationResult = SummarizationResult(error = error) if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiHubSheet = false
isRecapLoading = false
} else {
recapResult = SummarizationResult(error = error)
}
}, },
onFinish = { onFinish = {
isSummarizationLoading = false isSummarizationLoading = false
val fullSummary = finalSummaryBuilder.toString() val fullSummary = finalSummaryBuilder.toString()
if (fullSummary.isNotBlank()) { 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), modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true }, onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true }, onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true }, onOpenVisualOptions = { showVisualOptionsSheet = true },
onToggleReflow = if (onToggleReflow != null) { onToggleReflow = if (onToggleReflow != null) {
@ -3620,6 +3869,40 @@ fun EpubReaderHost(
label = "AutoScrollAlignAnimation" 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 val isAutoScrollControlsVisible = isAutoScrollModeActive
AnimatedVisibility( AnimatedVisibility(
@ -3725,7 +4008,7 @@ fun EpubReaderHost(
isProUser = isProUser, isProUser = isProUser,
hiddenTools = hiddenTools, hiddenTools = hiddenTools,
currentTtsMode = currentTtsMode, currentTtsMode = currentTtsMode,
onOpenTtsControls = { showTtsControlsSheet = true }, onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = { onOpenSlider = {
when (currentRenderMode) { when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> { RenderMode.VERTICAL_SCROLL -> {
@ -3768,90 +4051,6 @@ fun EpubReaderHost(
showBars = true showBars = true
showFormatAdjustmentBars = false 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 = { onToggleTts = {
if (isTtsSessionActive) { if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS") Timber.d("TTS button clicked: Stopping TTS")
@ -3874,9 +4073,6 @@ fun EpubReaderHost(
} }
} }
}, },
onPlayPauseTts = {
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
},
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = bottomPadding) .padding(bottom = bottomPadding)
@ -3922,27 +4118,21 @@ fun EpubReaderHost(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) )
val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) {
currentChapterInPaginatedMode ?: currentChapterIndex
} else {
currentChapterIndex
}
EpubReaderAiOverlays( EpubReaderAiOverlays(
showSummarizationPopup = showSummarizationPopup, bookTitle = epubBook.title,
summaryCacheManager = summaryCacheManager,
summarizationResult = summarizationResult, summarizationResult = summarizationResult,
isSummarizationLoading = isSummarizationLoading, isSummarizationLoading = isSummarizationLoading,
onDismissSummarization = {
showSummarizationPopup = false
isSummarizationLoading = false
summarizationResult = null
},
showSummarizationUpsellDialog = showSummarizationUpsellDialog, showSummarizationUpsellDialog = showSummarizationUpsellDialog,
onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false }, onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false },
showRecapPopup = showRecapPopup,
recapResult = recapResult, recapResult = recapResult,
isRecapLoading = isRecapLoading, isRecapLoading = isRecapLoading,
onDismissRecap = {
showRecapPopup = false
isRecapLoading = false
recapResult = null
},
showAiDefinitionPopup = showAiDefinitionPopup, showAiDefinitionPopup = showAiDefinitionPopup,
selectedTextForAi = selectedTextForAi, selectedTextForAi = selectedTextForAi,
aiDefinitionResult = aiDefinitionResult, aiDefinitionResult = aiDefinitionResult,
@ -3962,12 +4152,31 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive, isTtsSessionActive = isTtsSessionActive,
onOpenExternalDictionary = { text -> onOpenExternalDictionary = { text ->
if (!selectedDictPackage.isNullOrEmpty()) { if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text) ExternalDictionaryHelper.launchDictionary(
context,
selectedDictPackage!!,
text
)
} else { } 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 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) { if (isNavigatingToPosition) {
@ -4087,8 +4296,8 @@ fun EpubReaderHost(
highlightToNoteCfi = null highlightToNoteCfi = null
}, },
onCopy = { onCopy = {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = android.content.ClipData.newPlainText("Copied Text", targetHighlight.text) val clip = ClipData.newPlainText("Copied Text", targetHighlight.text)
clipboardManager.setPrimaryClip(clip) clipboardManager.setPrimaryClip(clip)
highlightToNoteCfi = null highlightToNoteCfi = null
}, },
@ -4176,15 +4385,9 @@ fun EpubReaderHost(
onSpeakerChange = { newSpeaker -> onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker) ttsController.changeSpeaker(newSpeaker)
}, },
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER" isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER",
) getAuthToken = { viewModel.getAuthToken() },
} bookTitle = epubBook.title
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
) )
} }
@ -4227,13 +4430,6 @@ fun EpubReaderHost(
) )
} }
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (showVisualOptionsSheet) { if (showVisualOptionsSheet) {
VisualOptionsSheet( VisualOptionsSheet(
systemUiMode = systemUiMode, 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) { if (showPaletteManager) {
PaletteManagerDialog( PaletteManagerDialog(
currentPalette = currentHighlightPalette, currentPalette = currentHighlightPalette,

View file

@ -114,7 +114,8 @@ fun TtsSessionObserver(
onToggleTtsStartOnLoad: (Boolean) -> Unit, onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean, userStoppedTts: Boolean,
scope: CoroutineScope, scope: CoroutineScope,
currentTtsMode: TtsMode currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?
) { ) {
val prevTtsState = remember { mutableStateOf(ttsState) } val prevTtsState = remember { mutableStateOf(ttsState) }
@ -154,7 +155,8 @@ fun TtsSessionObserver(
coverImagePath = coverImagePath, coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange, onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope, scope = scope,
ttsMode = currentTtsMode ttsMode = currentTtsMode,
getAuthToken = getAuthToken
) )
} }
} else if (wasPlaying && !isPlaying && !sessionFinished) { } else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -262,7 +264,8 @@ private fun handlePaginatedAutoAdvance(
coverImagePath: String?, coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit, onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope, scope: CoroutineScope,
ttsMode: TtsMode ttsMode: TtsMode,
getAuthToken: suspend () -> String?
) { ) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) { if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...") Timber.d("Paginated: Searching for next TTS content...")
@ -293,12 +296,16 @@ private fun handlePaginatedAutoAdvance(
val chapterTitle = chapters.getOrNull(chapterToTry)?.title val chapterTitle = chapters.getOrNull(chapterToTry)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() } val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
val token = getAuthToken()
ttsController.start( ttsController.start(
chunks = nextChapterChunks, chunks = nextChapterChunks,
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
ttsMode = ttsMode ttsMode = ttsMode,
playbackSource = "READER",
authToken = token
) )
foundContent = true foundContent = true
break break

View file

@ -257,16 +257,11 @@ class BookPaginator(
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> { private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
return blocks.flatMap { block -> return blocks.flatMap { block ->
when (block) { when (block) {
is WrappingContentBlock -> { is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap)
Timber.d("PAGINATOR: Found WrappingContentBlock with ${block.paragraphsToWrap.size} paragraphs.")
getAllTextBlocks(block.paragraphsToWrap)
}
is FlexContainerBlock -> getAllTextBlocks(block.children) is FlexContainerBlock -> getAllTextBlocks(block.children)
is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) }
is TextContentBlock -> listOf(block) is TextContentBlock -> listOf(block)
else -> { else -> emptyList()
Timber.d("PAGINATOR: Skipping non-text block of type ${block::class.simpleName}")
emptyList()
}
} }
} }
} }
@ -833,7 +828,17 @@ class BookPaginator(
override fun getPlainTextForChapter(chapterIndex: Int): String? { override fun getPlainTextForChapter(chapterIndex: Int): String? {
val chapter = chapters.getOrNull(chapterIndex) ?: return null 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 { private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
@ -1075,11 +1080,13 @@ class BookPaginator(
suspend fun findPageForLocator(locator: Locator): Int? { suspend fun findPageForLocator(locator: Locator): Int? {
val targetChapterIndex = locator.chapterIndex 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 chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0 val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) { if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.") Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null return null
@ -1088,57 +1095,87 @@ class BookPaginator(
var fallbackPageInChapter = -1 var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) { for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) { val allTextBlocks = getAllTextBlocks(page.content)
if (block.blockIndex == locator.blockIndex) { if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex") 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) { if (fallbackPageInChapter == -1) {
fallbackPageInChapter = pageIndex fallbackPageInChapter = pageIndex
} }
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
val textBlock = block as? TextContentBlock Timber.tag("POS_DIAG").d(" -> Block Match: page=$pageIndex, targetOffset=${locator.charOffset}, blockRange=[$startOffsetOnPage, $endOffsetOnPage]")
if (textBlock != null) {
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
Timber.tag("ThemeReconfig").d("Offset Check: Target ${locator.charOffset} vs Range [$startOffsetOnPage, $endOffsetOnPage]. Inside: $isInside") if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
return finalPageIndex
}
if (isInside) { if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
val finalPageIndex = chapterStartPage + pageIndex val finalPageIndex = chapterStartPage + pageIndex
return finalPageIndex Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
} return finalPageIndex
} else { }
return chapterStartPage + pageIndex }
}
if (fallbackPageInChapter == -1) {
for (block in page.content) {
if (block.blockIndex == locator.blockIndex) {
fallbackPageInChapter = pageIndex
break
} }
} }
} }
} }
Timber.tag("ThemeReconfig").e("Block Index NOT FOUND: Could not find block ${locator.blockIndex} in any page of Chapter $targetChapterIndex")
if (fallbackPageInChapter != -1) { if (fallbackPageInChapter != -1) {
val finalPageIndex = chapterStartPage + fallbackPageInChapter val finalPageIndex = chapterStartPage + fallbackPageInChapter
Timber.w("Locator offset not found. Using FALLBACK page. Final page index: $finalPageIndex") Timber.tag("POS_DIAG").w("findPageForLocator: Exact offset not found, using block-start fallback page $finalPageIndex")
return finalPageIndex return finalPageIndex
} }
Timber.e("Locator navigation FAILED. Block ${locator.blockIndex} not found in chapter $targetChapterIndex.") Timber.tag("POS_DIAG").e("findPageForLocator: FAILED to resolve locator in chapter $targetChapterIndex")
return null return null
} }
fun getLocatorForPage(pageIndex: Int): Locator? { fun getLocatorForPage(pageIndex: Int): Locator? {
val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null val chapterIndex = findChapterIndexForPage(pageIndex) ?: return null
val chStart = chapterStartPageIndices[chapterIndex] ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Request pageIndex=$pageIndex. Resolved chapterIndex=$chapterIndex (starts at $chStart). PageInChapter=${pageIndex - chStart}")
val pageContent = getPageContent(pageIndex) ?: return null val pageContent = getPageContent(pageIndex) ?: return null
val firstTextBlock = pageContent.content.firstOrNull { it is TextContentBlock } as? TextContentBlock Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")
val targetBlock = firstTextBlock ?: pageContent.content.firstOrNull() ?: return null
val charOffset = (targetBlock as? TextContentBlock)?.startCharOffsetInSource ?: 0
return Locator( val allTextBlocks = getAllTextBlocks(pageContent.content)
chapterIndex = chapterIndex, val firstTextBlock = allTextBlocks.firstOrNull { it.content.text.isNotBlank() } ?: allTextBlocks.firstOrNull()
blockIndex = targetBlock.blockIndex,
charOffset = charOffset Timber.tag("POS_DIAG").d("getLocatorForPage: allTextBlocks count=${allTextBlocks.size}. Selected blockIndex=${firstTextBlock?.blockIndex}, charOffset=${firstTextBlock?.startCharOffsetInSource}, text snippet='${firstTextBlock?.content?.text?.take(20)?.replace("\n", " ")}'")
)
if (firstTextBlock != null) {
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = firstTextBlock.blockIndex,
charOffset = firstTextBlock.startCharOffsetInSource
)
Timber.tag("POS_DIAG").d("getLocatorForPage: Generated $locator for absolute page $pageIndex")
return locator
} else {
val firstBlock = pageContent.content.firstOrNull() ?: return null
val locator = Locator(
chapterIndex = chapterIndex,
blockIndex = firstBlock.blockIndex,
charOffset = 0
)
Timber.tag("POS_DIAG").d("getLocatorForPage: Generated fallback $locator for absolute page $pageIndex")
return locator
}
} }
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) { override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) {

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily 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.Hyphens
import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp 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 { private fun embedImagesInSvg(svgContent: String): String {
@ -402,12 +414,44 @@ class ContentStyler(
else -> null else -> null
} }
val finalSpanStyle = themedSpanStyle.spanStyle.copy( var finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = effectiveSpanFontFamily, fontFamily = effectiveSpanFontFamily,
baselineShift = baselineShift 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<TextDecoration>()
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) 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) { if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end) addStringAnnotation("URL", span.linkHref, span.start, span.end)
} }

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.isSpecified
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.regex.Pattern import java.util.regex.Pattern
@ -427,6 +427,11 @@ object CssParser {
var marginBottomStr: String? = null var marginBottomStr: String? = null
var marginLeftStr: 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 borderTopWidth: Dp? = null
var borderRightWidth: Dp? = null var borderRightWidth: Dp? = null
var borderBottomWidth: Dp? = null var borderBottomWidth: Dp? = null
@ -566,14 +571,42 @@ object CssParser {
} }
} }
"text-decoration" -> { "text-decoration" -> {
spanStyle = spanStyle.copy( val parts = value.split(" ")
textDecoration = when(value) { val decos = mutableListOf<TextDecoration>()
"underline" -> TextDecoration.Underline
"line-through" -> TextDecoration.LineThrough if (parts.contains("underline")) decos.add(TextDecoration.Underline)
"none" -> TextDecoration.None if (parts.contains("line-through")) decos.add(TextDecoration.LineThrough)
else -> spanStyle.textDecoration
} 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" -> { "letter-spacing" -> {
val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density) val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density)
@ -623,9 +656,9 @@ object CssParser {
"padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)) "padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)) "padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx) "height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"background-color" -> { "background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified val originalColor = parseColor(value) ?: Color.Unspecified
@ -872,7 +905,10 @@ object CssParser {
borderCollapse = borderCollapse, borderCollapse = borderCollapse,
borderSpacing = borderSpacing 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<Dp> { private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List<Dp> {
@ -939,7 +975,46 @@ object CssParser {
return Triple(w, s, c) 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( internal fun parseCssSizeToDp(
size: String, size: String,
baseFontSizeSp: Float, baseFontSizeSp: Float,
@ -947,45 +1022,9 @@ object CssParser {
containerWidthPx: Int containerWidthPx: Int
): Dp { ): Dp {
val trimmed = size.trim().lowercase() val trimmed = size.trim().lowercase()
// Handle keywords
BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it } BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it }
val dim = parseCssDimension(size, baseFontSizeSp, density, containerWidthPx)
if (trimmed == "0" || trimmed == "0px") return 0.dp return if (dim.isSpecified) dim else 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
}
} }
internal fun parseColor(colorString: String): Color? { internal fun parseColor(colorString: String): Color? {

View file

@ -23,15 +23,18 @@ import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import timber.log.Timber import timber.log.Timber
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily 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.text.style.TextAlign
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.nodes.Node import org.jsoup.nodes.Node
@ -131,7 +134,7 @@ private class SemanticHtmlParser(
baseFontSizeSp = textStyle.fontSize.value, baseFontSizeSp = textStyle.fontSize.value,
density = density.density, density = density.density,
constraints = constraints, constraints = constraints,
isDarkTheme = false // Semantic parsing is always theme-agnostic isDarkTheme = false
) )
if (inlineParseResult.fontFaces.isNotEmpty()) { if (inlineParseResult.fontFaces.isNotEmpty()) {
@ -144,9 +147,7 @@ private class SemanticHtmlParser(
} }
val body = document.body() val body = document.body()
return body.children().flatMap { childElement -> return parseContainer(body, getElementStyle(body))
parseNodeToSemanticBlocks(childElement, getElementStyle(body))
}
} }
private fun parseNodeToSemanticBlocks( private fun parseNodeToSemanticBlocks(
@ -267,9 +268,15 @@ private class SemanticHtmlParser(
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) { if (hasBoxStyles) {
val children = element.children().flatMap { child -> val childStyle = elementStyle.copy(
parseNodeToSemanticBlocks(child, elementStyle) 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++)) listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else { } else {
parseContainer(element, elementStyle) parseContainer(element, elementStyle)
@ -280,16 +287,57 @@ private class SemanticHtmlParser(
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle) "math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList() "img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> { "h1", "h2", "h3", "h4", "h5", "h6" -> {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle) 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 (text.isNotBlank()) { if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1 val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++)) val fontSizeMultiplier = when (level) {
} else emptyList() 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++)) "hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle) "ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> { 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) parseContainer(element, elementStyle)
} else { } else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle) val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
@ -316,13 +364,30 @@ private class SemanticHtmlParser(
if (textNodesBuffer.isEmpty()) return if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style) val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) { 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() textNodesBuffer.clear()
} }
element.childNodes().forEach { node -> element.childNodes().forEach { node ->
if (node is Element) { 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) { if (isEffectivelyBlock) {
flushTextBuffer() flushTextBuffer()
@ -462,8 +527,12 @@ private class SemanticHtmlParser(
try { try {
BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) } .also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) } .let {
} catch (_: Exception) { 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) Pair(null, null)
} }
} }

View file

@ -142,6 +142,7 @@ class LocatorConverter(
} }
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) { 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) val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
var allBlocks: List<SemanticBlock>? = null var allBlocks: List<SemanticBlock>? = null
@ -167,14 +168,15 @@ class LocatorConverter(
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) { if (bestMatch != null) {
Timber.tag("PosSaveDiag").d("Found best match for baseCfiPath $baseCfiPath -> blockIndex=${bestMatch.blockIndex}, actualBlockCfi=${bestMatch.cfi}") val locator = Locator(
Locator(
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex, blockIndex = bestMatch.blockIndex,
charOffset = charOffset charOffset = charOffset
) )
Timber.tag("POS_DIAG").d("getLocatorFromCfi: Successfully resolved to $locator")
locator
} else { } 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 null
} }
} }
@ -201,15 +203,20 @@ class LocatorConverter(
.filter { it.cfi != null } .filter { it.cfi != null }
.map { block -> .map { block ->
val blockCfi = block.cfi!! 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 i = inputCfi.length - 1
var j = blockCfi.length - 1 var j = blockCfi.length - 1
var length = 0 var suffixScore = 0
while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) { while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) {
length++ suffixScore++
i-- i--
j-- j--
} }
Pair(block, length)
Pair(block, maxOf(prefixScore, suffixScore))
} }
.maxByOrNull { it.second } .maxByOrNull { it.second }
?.first ?.first
@ -218,6 +225,7 @@ class LocatorConverter(
} }
suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) { 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) val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
var blocks: List<SemanticBlock>? = null var blocks: List<SemanticBlock>? = null
@ -236,13 +244,15 @@ class LocatorConverter(
} }
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex) val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
foundBlock?.cfi?.let { cfi -> val resultCfi = foundBlock?.cfi?.let { cfi ->
if (locator.charOffset > 0) { if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}" "$cfi:${locator.charOffset}"
} else { } else {
cfi cfi
} }
} }
Timber.tag("POS_DIAG").d("getCfiFromLocator: Resulting CFI='$resultCfi'")
resultCfi
} }
private fun findBlockByBlockIndex(blocks: List<SemanticBlock>, targetBlockIndex: Int): SemanticBlock? { private fun findBlockByBlockIndex(blocks: List<SemanticBlock>, targetBlockIndex: Int): SemanticBlock? {

View file

@ -1,4 +1,6 @@
// PaginatedReader.kt // PaginatedReader.kt
@file:Suppress("VariableNeverRead")
package com.aryan.reader.paginatedreader package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint import android.annotation.SuppressLint
@ -9,6 +11,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background 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.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -49,6 +51,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf 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.ColorMatrix
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect 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.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath 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) { LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) { if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.") Timber.d("Formatting changed. Waiting for debounce.")
@ -755,7 +772,7 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator) { LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) { 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() snapshotFlow { paginator.isLoading }.filter { !it }.first()
@ -763,19 +780,15 @@ fun PaginatedReaderScreen(
if (targetLocator != null) { if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator) val page = paginator.findPageForLocator(targetLocator)
Timber.tag("ThemeReconfig").d(""" Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
Restoration Progress:
- Target Locator: $targetLocator
- Paginator found Page: $page
- Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]}
""".trimIndent())
if (page != null) { if (page != null) {
pagerState.scrollToPage(page) pagerState.scrollToPage(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else { } else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex] val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) { 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) pagerState.scrollToPage(startPage)
} }
} }
@ -831,7 +844,15 @@ fun PaginatedReaderScreen(
textStyle = textStyle, textStyle = textStyle,
horizontalPadding = horizontalPadding, horizontalPadding = horizontalPadding,
verticalPadding = verticalPadding, 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) }, onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
onGetChapterInfo = { pageIndex -> onGetChapterInfo = { pageIndex ->
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex -> paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
@ -1186,8 +1207,206 @@ private fun TextWithEmphasis(
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pressedHighlightCfi by remember { mutableStateOf<String?>(null) } var pressedHighlightCfi by remember { mutableStateOf<String?>(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<Pair<Path, Color>>()
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<EmphasisMarkInfo>()
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<UnderlineDrawInfo>()
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<AnnotatedString.Range<String>>()
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 customDrawer = Modifier.drawBehind {
val drawStartTime = System.currentTimeMillis()
textLayoutResult?.let { layoutResult -> textLayoutResult?.let { layoutResult ->
if (activeSelection != null) { if (activeSelection != null) {
// ADD absolute offset helper: // ADD absolute offset helper:
@ -1219,48 +1438,60 @@ private fun TextWithEmphasis(
val path = layoutResult.getPathForRange(sOffset, eOffset) val path = layoutResult.getPathForRange(sOffset, eOffset)
drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f)) drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f))
} catch (e: Exception) { } 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()) { cachedHighlights.forEach { (path, color) ->
userHighlights.forEach { highlight -> drawPath(path, color, blendMode = BlendMode.SrcOver)
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) { }
}
}
} }
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length) cachedEmphasisMarks.forEach { mark ->
if (emphasisAnnotations.isNotEmpty()) { drawCircle(mark.color, mark.radius, mark.center, style = Stroke(1f))
emphasisAnnotations.forEach { annotation -> }
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color cachedUnderlines.forEach { line ->
val markSize = layoutResult.layoutInput.style.fontSize.toPx() * 0.3f when (line.decoStyle) {
for (offset in annotation.start until annotation.end) { "wavy" -> {
if (offset >= text.text.length || text.text[offset].isWhitespace()) continue line.path?.let { p ->
try { drawPath(p, color = line.decoColor, style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
val boundingBox = layoutResult.getBoundingBox(offset) }
val center = Offset( }
boundingBox.center.x, "dashed", "dotted" -> {
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f drawLine(
else boundingBox.top - markSize * 0.1f 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<UserHighlight, Rect>? { fun getHighlightAt(offset: Offset, layout: TextLayoutResult): Pair<UserHighlight, Rect>? {
@ -1568,6 +1799,7 @@ internal fun PaginatedReaderContent(
val down = event.changes.firstOrNull { it.pressed } val down = event.changes.firstOrNull { it.pressed }
if (down != null) { if (down != null) {
pageTurnTouchY = down.position.y 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<String?>(null) } var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) { LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
pageContent = onGetPage(pageIndex) pageContent = onGetPage(pageIndex)
val fetchDuration = System.currentTimeMillis() - fetchStartTime
Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it } onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
} }
SideEffect {
Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area")
}
val textBlocksOnPage = val textBlocksOnPage =
pageContent?.content?.extractTextBlocks() pageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList() ?.filter { it.cfi != null } ?: emptyList()
@ -2263,10 +2506,6 @@ internal fun PaginatedReaderContent(
} }
is FlexContainerBlock -> { 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") { if (block.style.flexDirection == "row") {
val horizontalArrangement = val horizontalArrangement =
@ -2284,7 +2523,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Top else -> Alignment.Top
} }
Row( Row(
modifier = containerModifier.fillMaxWidth(), modifier = paddingModifier.fillMaxWidth(),
horizontalArrangement = horizontalArrangement, horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment verticalAlignment = verticalAlignment
) { ) {
@ -2336,7 +2575,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Start else -> Alignment.Start
} }
Column( Column(
modifier = containerModifier.fillMaxWidth(), modifier = paddingModifier.fillMaxWidth(),
verticalArrangement = verticalArrangement, verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment horizontalAlignment = horizontalAlignment
) { ) {
@ -2505,27 +2744,21 @@ internal fun PaginatedReaderContent(
is ImageBlock -> { is ImageBlock -> {
val style = block.style val style = block.style
val finalImageModifier = Modifier.then( val finalImageModifier = Modifier.then(
if (style.width != Dp.Unspecified) Modifier.width( if (style.width.isSpecified && style.width > 0.dp) Modifier.width(style.width)
style.width else Modifier.fillMaxWidth()
) ).then(
if (style.maxWidth.isSpecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth)
else Modifier else Modifier
).then( ).then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn( if (block.expectedHeight > 0) {
max = style.maxWidth Modifier.height(with(density) { block.expectedHeight.toDp() })
)
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)
} else { } else {
Modifier.height(250.dp) Modifier.height(250.dp)
} }
).then(paddingModifier) ).then(paddingModifier)
.onGloballyPositioned { coords ->
Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px")
}
val colorFilter = val colorFilter =
if (block.style.filter == "invert(100%)") { if (block.style.filter == "invert(100%)") {
@ -2732,24 +2965,13 @@ internal fun PaginatedReaderContent(
} }
is ImageBlock -> { is ImageBlock -> {
val imageModifier = val imageModifier = Modifier.fillMaxWidth().then(
Modifier.fillMaxWidth() if (blockInCell.expectedHeight > 0) {
.then( Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { } else {
Modifier.aspectRatio( Modifier.height(250.dp)
blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, }
matchHeightConstraintsFirst = false )
)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(
blockInCell.style.height
)
} else {
Modifier.height(
250.dp
)
}
)
AsyncImage( AsyncImage(
model = Builder( model = Builder(
LocalContext.current LocalContext.current
@ -3457,18 +3679,16 @@ private fun RenderFlexChildBlock(
val style = childBlock.style val style = childBlock.style
val imageModifier = Modifier val imageModifier = Modifier
.then( .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 else Modifier
) )
.then( .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 else Modifier
) )
.then( .then(
if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) { if (childBlock.expectedHeight > 0) {
Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false) Modifier.height(with(density) { childBlock.expectedHeight.toDp() })
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
} else { } else {
Modifier.height(250.dp) Modifier.height(250.dp)
} }
@ -3582,10 +3802,8 @@ private fun RenderFlexChildBlock(
) )
} else if (blockInCell is ImageBlock) { } else if (blockInCell is ImageBlock) {
val imageModifier = Modifier.fillMaxWidth().then( val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) { if (blockInCell.expectedHeight > 0) {
Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false) Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(blockInCell.style.height)
} else { } else {
Modifier.height(250.dp) Modifier.height(250.dp)
} }
@ -3625,6 +3843,11 @@ private fun Modifier.realisticBookPage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
touchY: Float? touchY: Float?
): Modifier = composed { ): Modifier = composed {
// Log composition frequency
SideEffect {
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
}
val frontPath = remember { Path() } val frontPath = remember { Path() }
val backPath = remember { Path() } val backPath = remember { Path() }
val reflectedScreenPath = remember { Path() } val reflectedScreenPath = remember { Path() }
@ -3633,6 +3856,11 @@ private fun Modifier.realisticBookPage(
.graphicsLayer { .graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction 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) { if (pageOffset <= 1f && pageOffset > -1f) {
translationX = -pageOffset * size.width translationX = -pageOffset * size.width
} }
@ -3644,6 +3872,7 @@ private fun Modifier.realisticBookPage(
} }
} }
.drawWithContent { .drawWithContent {
val drawStart = System.nanoTime()
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
if (abs(pageOffset) < 0.001f) { if (abs(pageOffset) < 0.001f) {
@ -3670,10 +3899,21 @@ private fun Modifier.realisticBookPage(
val dy = cornerY - dragY val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy) 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) { if (nLen > 0f) {
val nx = dx / nLen val nx = dx / nLen
val ny = dy / 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 huge = w * 3f
val vx = -ny val vx = -ny
@ -3733,17 +3973,12 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) { clipRect(0f, 0f, w, h) {
clipPath(frontPath) { clipPath(frontPath) {
drawPath(reflectedScreenPath, color = paperColor) drawPath(reflectedScreenPath, color = paperColor)
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f) val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint) drawPath(reflectedScreenPath, color = flapTint)
val innerShadowWidth = shadowWidth * 0.7f val innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient( val innerShadowBrush = Brush.linearGradient(
colors = listOf( colors = listOf(Color.Black.copy(alpha = 0.25f), Color.Black.copy(alpha = 0.05f), Color.Transparent),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.05f),
Color.Transparent
),
start = Offset(midX, midY), start = Offset(midX, midY),
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth) end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
) )
@ -3769,16 +4004,15 @@ private fun Modifier.realisticBookPage(
drawContent() 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 { else {
drawRect(color = paperColor) drawRect(color = paperColor)
drawContent() 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")
}
} }
} }

View file

@ -303,7 +303,11 @@ data class CssStyle(
@ProtoNumber(9) val content: String? = null, @ProtoNumber(9) val content: String? = null,
@ProtoNumber(10) val hyphens: String? = null, @ProtoNumber(10) val hyphens: String? = null,
@ProtoNumber(11) val fontVariantNumeric: 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 { fun merge(other: CssStyle): CssStyle {
return CssStyle( return CssStyle(
@ -318,7 +322,11 @@ data class CssStyle(
content = other.content ?: this.content, content = other.content ?: this.content,
hyphens = other.hyphens ?: this.hyphens, hyphens = other.hyphens ?: this.hyphens,
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric, 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
) )
} }
} }

View file

@ -742,26 +742,25 @@ private suspend fun measureBlockHeight(
val imageIntrinsicWidth = block.intrinsicWidth val imageIntrinsicWidth = block.intrinsicWidth
val imageIntrinsicHeight = block.intrinsicHeight val imageIntrinsicHeight = block.intrinsicHeight
if (imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0) { val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
val styledWidthDp = block.style.width
val imageRenderWidthPx = if (styledWidthDp != Dp.Unspecified) { val measuredHeight = when {
with(density) { styledWidthDp.toPx() } styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
} else { styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
contentMaxWidth val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
styledWidthPx * aspectRatio
} }
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val height = (imageRenderWidthPx * aspectRatio).roundToInt() val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
height contentMaxWidth * aspectRatio
} 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() }
} }
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 -> { is SpacerBlock -> {
val height = with(density) { block.height.toPx().roundToInt() } val height = with(density) { block.height.toPx().roundToInt() }

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class, ConfigurationCache::class,
AnchorIndexEntry::class AnchorIndexEntry::class
], ],
version = 6, version = 7,
exportSchema = false exportSchema = false
) )
abstract class BookCacheDatabase : RoomDatabase() { abstract class BookCacheDatabase : RoomDatabase() {

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 6 const val LATEST_PROCESSING_VERSION = 7
@Entity(tableName = "processed_books") @Entity(tableName = "processed_books")
data class ProcessedBook( data class ProcessedBook(

View file

@ -439,6 +439,7 @@ internal fun PdfPageComposable(
isVisible: Boolean = true, isVisible: Boolean = true,
isActivePage: Boolean = true, isActivePage: Boolean = true,
isStylusOnlyMode: Boolean = false, isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false, isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(), userHighlights: List<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> }, onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
@ -1073,6 +1074,7 @@ internal fun PdfPageComposable(
canvasHeightPx.floatValue, canvasHeightPx.floatValue,
isVerticalScroll, isVerticalScroll,
isScrolling, isScrolling,
isAutoScrollPlaying,
virtualPage, virtualPage,
isActivePage isActivePage
) { ) {
@ -1109,7 +1111,17 @@ internal fun PdfPageComposable(
try { try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) } 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) delay(150)
@ -1120,6 +1132,8 @@ internal fun PdfPageComposable(
return@collectLatest return@collectLatest
} }
val currentVisibleRect = visibleScreenRect()
val pxTl: Float val pxTl: Float
val pxBr: Float val pxBr: Float
val pyTl: Float val pyTl: Float

View file

@ -1614,6 +1614,7 @@ internal fun PdfVerticalReader(
selectedTool = selectedTool, selectedTool = selectedTool,
richTextController = richTextController, richTextController = richTextController,
isStylusOnlyMode = isStylusOnlyMode, isStylusOnlyMode = isStylusOnlyMode,
isAutoScrollPlaying = isAutoScrollPlaying,
textBoxes = textBoxes.filter { it.pageIndex == page.index }, textBoxes = textBoxes.filter { it.pageIndex == page.index },
selectedTextBoxId = selectedTextBoxId, selectedTextBoxId = selectedTextBoxId,
onTextBoxChange = onTextBoxChange, onTextBoxChange = onTextBoxChange,

View file

@ -32,7 +32,6 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF
@ -269,8 +268,8 @@ import androidx.paging.compose.itemKey
import androidx.work.WorkInfo import androidx.work.WorkInfo
import com.aryan.reader.AiDefinitionPopup import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.HighlightColorPickerDialog import com.aryan.reader.HighlightColorPickerDialog
import com.aryan.reader.MainViewModel import com.aryan.reader.MainViewModel
@ -279,15 +278,14 @@ import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderThemePanel import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TooltipIconButton import com.aryan.reader.TooltipIconButton
import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords
import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper 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.fetchAiDefinition
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
@ -306,7 +304,6 @@ import com.aryan.reader.saveCustomThemes
import com.aryan.reader.summarizationUrl import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.api.Bookmark import io.legere.pdfiumandroid.api.Bookmark
@ -1099,7 +1096,7 @@ private fun PdfTocTreeItem(
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Suppress("unused") @Suppress("unused")
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) { 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) } prefs.edit { putString(TTS_MODE_KEY, mode.name) }
} }
@ -1229,7 +1226,9 @@ fun PdfViewerScreen(
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) } var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
val documentCache = remember { DocumentCache(3) } val documentCache = remember { DocumentCache(3) }
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
val tabStateMap = remember { mutableStateMapOf<String, Int>() } val tabStateMap = remember { mutableStateMapOf<String, Int>() }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
val activeTheme = remember(currentThemeId, customThemes) { val activeTheme = remember(currentThemeId, customThemes) {
PdfBuiltInThemes.find { it.id == currentThemeId } PdfBuiltInThemes.find { it.id == currentThemeId }
@ -1298,6 +1297,7 @@ fun PdfViewerScreen(
} }
var currentBookId by remember { mutableStateOf<String?>(null) } var currentBookId by remember { mutableStateOf<String?>(null) }
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString() val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
val view = LocalView.current val view = LocalView.current
var isDockDragging by remember { mutableStateOf(false) } var isDockDragging by remember { mutableStateOf(false) }
var initialScrollDone by remember { mutableStateOf(false) } var initialScrollDone by remember { mutableStateOf(false) }
@ -1320,14 +1320,24 @@ fun PdfViewerScreen(
var isAutoScrollTempPaused by remember { mutableStateOf(false) } var isAutoScrollTempPaused by remember { mutableStateOf(false) }
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) } val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
var isAutoScrollCollapsed by remember { mutableStateOf(false) } var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) } var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) } var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(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 showTtsControlsSheet by remember { mutableStateOf(false) }
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) } 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) { DisposableEffect(isKeepScreenOn) {
view.keepScreenOn = isKeepScreenOn view.keepScreenOn = isKeepScreenOn
@ -1342,8 +1352,6 @@ fun PdfViewerScreen(
var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) } var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) }
var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) } var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
fun triggerAutoScrollTempPause(durationMs: Long) { fun triggerAutoScrollTempPause(durationMs: Long) {
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
autoScrollResumeJob.value?.cancel() 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 annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState() val toolSettings by annotationSettingsRepo.settings.collectAsState()
var showToolSettings by rememberSaveable { mutableStateOf(false) } var showToolSettings by rememberSaveable { mutableStateOf(false) }
@ -2648,7 +2669,7 @@ fun PdfViewerScreen(
var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) } var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) }
var showSummarizationPopup by remember { mutableStateOf(false) } var showAiHubSheet by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) } var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isSummarizationLoading by remember { mutableStateOf(false) } var isSummarizationLoading by remember { mutableStateOf(false) }
@ -2659,17 +2680,18 @@ fun PdfViewerScreen(
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) } val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) } var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
val speakerPlayer = val speakerPlayer = remember(context, coroutineScope) {
remember(context, coroutineScope) { SpeakerSamplePlayer(context, coroutineScope) } SpeakerSamplePlayer(
context = context,
scope = coroutineScope,
getAuthToken = { viewModel.getAuthToken() }
)
}
var clickedLinkUrl by remember { mutableStateOf<String?>(null) } var clickedLinkUrl by remember { mutableStateOf<String?>(null) }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
ttsState.currentText
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) } var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
var isOcrModelDownloading by remember { mutableStateOf(false) } 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 -> { text: String ->
executeWithOcrCheck { executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss" val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) { if (effectiveUseOnline) {
val wordCount = countWords(text) val wordCount = com.aryan.reader.countWords(text)
if (isProUser || wordCount <= 1) { if (wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = text selectedTextForAi = text
showAiDefinitionPopup = true showAiDefinitionPopup = true
coroutineScope.launch { coroutineScope.launch {
val token = viewModel.getAuthToken()
isAiDefinitionLoading = true isAiDefinitionLoading = true
aiDefinitionResult = null aiDefinitionResult = null
fetchAiDefinition( fetchAiDefinition(
text = text, onUpdate = { chunk -> text = text,
val currentDefinition = aiDefinitionResult?.definition ?: "" authToken = token,
aiDefinitionResult = AiDefinitionResult( onUpdate = { chunk ->
definition = currentDefinition + chunk val currentDefinition = aiDefinitionResult?.definition ?: ""
) aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
}, onError = { error -> },
aiDefinitionResult = AiDefinitionResult(error = error) onError = { error ->
}, onFinish = { if (error == "INSUFFICIENT_CREDITS") {
isAiDefinitionLoading = false showInsufficientCreditsDialog = true
}, context = context showAiDefinitionPopup = false
isAiDefinitionLoading = false
} else {
aiDefinitionResult = AiDefinitionResult(error = error)
}
},
onFinish = { isAiDefinitionLoading = false },
context = context
) )
} }
} else {
showDictionaryUpsellDialog = true
} }
} else { } else {
if (!selectedDictPackage.isNullOrEmpty()) { if (!selectedDictPackage.isNullOrEmpty()) {
@ -2835,6 +2865,7 @@ fun PdfViewerScreen(
} }
suspend fun summarizeCurrentPage( suspend fun summarizeCurrentPage(
authToken: String?,
onUpdate: (SummarizationResult) -> Unit, onFinish: () -> Unit onUpdate: (SummarizationResult) -> Unit, onFinish: () -> Unit
) { ) {
val currentPageIndex = currentPage val currentPageIndex = currentPage
@ -2892,28 +2923,50 @@ fun PdfViewerScreen(
put("content_type", "image") put("content_type", "image")
put("data", base64Image) put("data", base64Image)
} }
if (authToken != null) {
connection.setRequestProperty("Authorization", "Bearer $authToken")
}
connection.outputStream.use { os -> connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8)) os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
} }
val responseCode = connection.responseCode val responseCode = connection.responseCode
Timber.d("Summarization API response code: $responseCode") Timber.d("Summarization API response code: $responseCode")
if (responseCode == 402) {
onUpdate(SummarizationResult(error = "INSUFFICIENT_CREDITS"))
onFinish()
return@withContext
}
if (responseCode == HttpURLConnection.HTTP_OK) { if (responseCode == HttpURLConnection.HTTP_OK) {
val fullText = StringBuilder() val fullText = StringBuilder()
var lastResult: SummarizationResult? = null var lastResult: SummarizationResult? = null
var currentCost: Double? = null
var currentFreeRemaining: Int? = null
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String? var line: String?
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
try { try {
val jsonResponse = JSONObject(line!!) 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 { jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
fullText.append(it) fullText.append(it)
lastResult = SummarizationResult(summary = fullText.toString()) lastResult = SummarizationResult(summary = fullText.toString(), cost = currentCost, freeRemaining = currentFreeRemaining)
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION") onUpdate(lastResult!!) onUpdate(lastResult!!)
} }
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let { jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
lastResult = SummarizationResult(error = it) lastResult = SummarizationResult(error = it, cost = currentCost, freeRemaining = currentFreeRemaining)
onUpdate(lastResult) onUpdate(lastResult)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -3032,11 +3085,17 @@ fun PdfViewerScreen(
} }
fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) { 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") Timber.d("TTS button clicked: Starting TTS for current page/selection")
if (pdfDocument == null || totalPages == 0) { if (pdfDocument == null || totalPages == 0) {
return return
} }
coroutineScope.launch { coroutineScope.launch {
val token = viewModel.getAuthToken()
val pageToRead = pageToReadOverride ?: currentPage val pageToRead = pageToReadOverride ?: currentPage
var rawPageText: String? = null var rawPageText: String? = null
var tempPage: ReaderPage? = null var tempPage: ReaderPage? = null
@ -3108,7 +3167,8 @@ fun PdfViewerScreen(
chapterTitle = pageTitle, chapterTitle = pageTitle,
coverImageUri = null, coverImageUri = null,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER" playbackSource = "READER",
authToken = token
) )
if (isAutoPagingForTts) { if (isAutoPagingForTts) {
@ -3269,6 +3329,7 @@ fun PdfViewerScreen(
isLoadingDocument = true isLoadingDocument = true
isDocumentReady = false isDocumentReady = false
errorMessage = null errorMessage = null
documentMetadataTitle = null
if (showPasswordDialog) isPasswordError = false if (showPasswordDialog) isPasswordError = false
@ -3336,6 +3397,7 @@ fun PdfViewerScreen(
} }
pdfDocument = doc pdfDocument = doc
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
pfdState = currentPfdOpened pfdState = currentPfdOpened
val pagesCount = doc.getPageCount() val pagesCount = doc.getPageCount()
@ -3525,6 +3587,7 @@ fun PdfViewerScreen(
} }
} }
previousPage = currentPage previousPage = currentPage
summarizationResult = null
} }
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
@ -3862,7 +3925,7 @@ fun PdfViewerScreen(
showBars = true showBars = true
} }
showSummarizationPopup -> showSummarizationPopup = false showAiHubSheet -> showAiHubSheet = false
showPermissionRationaleDialog -> showPermissionRationaleDialog = false showPermissionRationaleDialog -> showPermissionRationaleDialog = false
showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false
showAiDefinitionPopup -> showAiDefinitionPopup = false showAiDefinitionPopup -> showAiDefinitionPopup = false
@ -3991,55 +4054,103 @@ fun PdfViewerScreen(
} }
} }
Box(modifier = Modifier.fillMaxSize()) { val onScrollToCurrent = {
LazyColumn( drawerScope.launch {
state = listState, val targetEntry = currentTocEntry ?: return@launch
modifier = Modifier val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
.fillMaxHeight() if (targetOriginalIndex != -1) {
.padding(end = 12.dp) var currentLevel = targetEntry.nestLevel
) { val newExpanded = expandedEntryIndices.toMutableSet()
items( for (i in targetOriginalIndex downTo 0) {
items = visibleItemInfo, val entry = flatTableOfContents[i]
key = { it.second.title + it.first } if (entry.nestLevel < currentLevel) {
) { item -> newExpanded.add(i)
val (originalIndex, entry) = item currentLevel = entry.nestLevel
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)
}
}
} }
) }
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( HorizontalDivider()
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd) 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, richTextController = richTextController,
isStylusOnlyMode = isStylusOnlyMode, isStylusOnlyMode = isStylusOnlyMode,
isAutoScrollPlaying = isAutoScrollPlaying,
isHighlighterSnapEnabled = isHighlighterSnapEnabled, isHighlighterSnapEnabled = isHighlighterSnapEnabled,
isEditMode = isDrawingActive, isEditMode = isDrawingActive,
textBoxes = textBoxes.filter { it.pageIndex == pageIndex }, textBoxes = textBoxes.filter { it.pageIndex == pageIndex },
@ -5833,9 +5945,10 @@ fun PdfViewerScreen(
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) { if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) }, text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive,
onClick = { onClick = {
showMoreMenu = false showMoreMenu = false
showDeviceVoiceSettingsSheet = true showTtsSettingsSheet = true
}, },
leadingIcon = { leadingIcon = {
Icon( 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)) { if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(text = { DropdownMenuItem(text = {
@ -6429,43 +6524,15 @@ fun PdfViewerScreen(
// AI feat // AI feat
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) { if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
Box { TooltipIconButton(
var showAiFeaturesMenu by remember { mutableStateOf(false) } text = stringResource(R.string.tooltip_ai),
TooltipIconButton( description = stringResource(R.string.tooltip_ai_desc),
text = stringResource(R.string.tooltip_ai), onClick = { showAiHubSheet = true }
description = stringResource(R.string.tooltip_ai_desc), ) {
onClick = { showAiFeaturesMenu = true } Icon(
) { painter = painterResource(id = R.drawable.ai),
Icon( contentDescription = stringResource(R.string.tooltip_ai)
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
)
}
} }
} }
@ -6506,72 +6573,25 @@ fun PdfViewerScreen(
// TTS // TTS
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) { if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
Box { TooltipIconButton(
Row(verticalAlignment = Alignment.CenterVertically) { text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
TooltipIconButton( else stringResource(R.string.tooltip_tts_start),
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop) description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
else stringResource(R.string.tooltip_tts_start), else stringResource(R.string.tooltip_tts_start_desc),
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc) onClick = {
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)
)
}
if (isTtsSessionActive) { if (isTtsSessionActive) {
TooltipIconButton( Timber.d("TTS button clicked: Stopping TTS")
text = if (ttsState.isPlaying) ttsController.stop()
stringResource(R.string.tooltip_tts_pause) } else {
else startTtsWithPermissionCheck(null, null)
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)
)
}
}
} }
} }) {
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) { if (showAiHubSheet) {
SummarizationPopup( val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) {
title = "Page Summary", pagerState.currentPage
result = summarizationResult, } else {
isLoading = isSummarizationLoading, verticalReaderState.currentPage
onDismiss = { showSummarizationPopup = false }, }
isMainTtsActive = isTtsSessionActive 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) { if (showPermissionRationaleDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false }, 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) { if (showPasswordDialog) {
PasswordDialog( PasswordDialog(
isError = isPasswordError, isError = isPasswordError,
@ -7342,7 +7438,8 @@ fun PdfViewerScreen(
showDictionarySettingsSheet = true showDictionarySettingsSheet = true
} }
} }
} },
getAuthToken = { viewModel.getAuthToken() }
) )
} }
if (showDictionaryUpsellDialog) { if (showDictionaryUpsellDialog) {
@ -7455,6 +7552,7 @@ fun PdfViewerScreen(
} }
if (showTtsSettingsSheet) { if (showTtsSettingsSheet) {
val bookTitle = documentMetadataTitle ?: originalFileName
TtsSettingsSheet( TtsSettingsSheet(
isVisible = true, isVisible = true,
onDismiss = { showTtsSettingsSheet = false }, onDismiss = { showTtsSettingsSheet = false },
@ -7468,15 +7566,9 @@ fun PdfViewerScreen(
onSpeakerChange = { newSpeaker -> onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker) ttsController.changeSpeaker(newSpeaker)
}, },
isTtsActive = isTtsSessionActive isTtsActive = isTtsSessionActive,
) getAuthToken = { viewModel.getAuthToken() },
} bookTitle = bookTitle
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
) )
} }
@ -7508,13 +7600,6 @@ fun PdfViewerScreen(
) )
} }
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (highlightToNoteId != null) { if (highlightToNoteId != null) {
val targetHighlight = userHighlights.find { it.id == highlightToNoteId } val targetHighlight = userHighlights.find { it.id == highlightToNoteId }
if (targetHighlight != null) { if (targetHighlight != null) {
@ -7777,6 +7862,39 @@ fun PdfViewerScreen(
label = "AutoScrollPadding" 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 isAutoScrollControlsVisible = isAutoScrollModeActive
val alignmentBias by animateFloatAsState( val alignmentBias by animateFloatAsState(

View file

@ -37,6 +37,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig 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.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
@ -65,15 +67,12 @@ private fun loadSpeaker(context: Context): String {
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Suppress("KotlinConstantConditions")
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode { fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name) val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name ?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.DEBUG && val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
BuildConfig.IS_PRO &&
BuildConfig.TTS_WORKER_URL.isNotBlank()
return if (isCloudAllowed) { return if (isCloudAllowed) {
try { try {
@ -159,7 +158,8 @@ class TtsController(context: Context) : Player.Listener {
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
ttsMode: TtsPlaybackManager.TtsMode, ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER" playbackSource: String = "READER",
authToken: String? = null
) { ) {
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
Timber.w("TtsController: start called with empty chunks!") 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_COVER_IMAGE_URI, coverImageUri)
putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource) 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) 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() { fun pause() {
@ -224,6 +217,10 @@ class TtsController(context: Context) : Player.Listener {
@Suppress("unused") @Suppress("unused")
fun changeTtsMode(mode: String) { fun changeTtsMode(mode: String) {
Timber.d("UI sending CHANGE_TTS_MODE command.") 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 { val args = Bundle().apply {
putString(KEY_TTS_MODE, mode) putString(KEY_TTS_MODE, mode)
} }
@ -261,6 +258,7 @@ class TtsController(context: Context) : Player.Listener {
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1 val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
val currentWordSourceCfi = customState.getString("currentWordSourceCfi") val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1) val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode)
val currentState = _ttsState.value val currentState = _ttsState.value
_ttsState.value = currentState.copy( _ttsState.value = currentState.copy(
@ -288,11 +286,22 @@ class TtsController(context: Context) : Player.Listener {
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null, currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1, currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
sessionFinished = sessionFinished, 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() { fun release() {
pollingJob?.cancel() pollingJob?.cancel()
scope.cancel() scope.cancel()

View file

@ -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) 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 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 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_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS" 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_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS" const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE" 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 @UnstableApi
class TtsPlaybackManager( class TtsPlaybackManager(
private val player: Player, 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 { ) : MediaSession.Callback, Player.Listener {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
private val prefetchingJobs = mutableMapOf<Int, Job>() private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
private var wordTrackingJob: Job? = null private var wordTrackingJob: Job? = null
private var preparationJob: 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<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
enum class TtsMode { enum class TtsMode {
CLOUD, BASE CLOUD, BASE
@ -100,13 +108,14 @@ class TtsPlaybackManager(
val currentWordSourceCfi: String? = null, val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1, val currentWordStartOffset: Int = -1,
val sessionFinished: Boolean = false, val sessionFinished: Boolean = false,
val playbackSource: String? = null val playbackSource: String? = null,
val ttsMode: String = TtsMode.CLOUD.name
) )
private val _ttsState = MutableStateFlow(TtsState()) private val _ttsState = MutableStateFlow(TtsState())
private var textChunks: List<TtsChunk> = emptyList() private var textChunks: List<TtsChunk> = emptyList()
private var audioFiles: MutableMap<Int, File> = mutableMapOf() private val audioFiles = java.util.concurrent.ConcurrentHashMap<Int, File>()
private var currentSpeakerId = DEFAULT_SPEAKER_ID private var currentSpeakerId = DEFAULT_SPEAKER_ID
private var bookTitle: String? = null private var bookTitle: String? = null
private var chapterTitle: String? = null private var chapterTitle: String? = null
@ -141,6 +150,7 @@ class TtsPlaybackManager(
.add(CHANGE_TTS_MODE_COMMAND) .add(CHANGE_TTS_MODE_COMMAND)
.add(FLUSH_PREFETCH_COMMAND) .add(FLUSH_PREFETCH_COMMAND)
.add(SLICE_CURRENT_AND_RELOAD_COMMAND) .add(SLICE_CURRENT_AND_RELOAD_COMMAND)
.add(SET_PLAYBACK_PARAMS_COMMAND)
.build() .build()
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@ -192,7 +202,9 @@ class TtsPlaybackManager(
chunks.map { TtsChunk(it, "", -1) } 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 -> { STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.") Timber.d("Received STOP command.")
@ -209,23 +221,55 @@ class TtsPlaybackManager(
} }
FLUSH_PREFETCH_COMMAND -> { FLUSH_PREFETCH_COMMAND -> {
Timber.d("Flushing prefetched TTS chunks for new parameters.") Timber.d("Flushing prefetched TTS chunks for new parameters.")
onResetContext()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear() 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 if (currentIdx == C.INDEX_UNSET) return@launch
val keysToRemove = audioFiles.keys.filter { it > currentIdx }
keysToRemove.forEach { key -> val keysToRemove = loadedChunks.filter { it > currentIdx }
audioFiles.remove(key)?.delete() 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<Int>()
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 -> { SLICE_CURRENT_AND_RELOAD_COMMAND -> {
handleSliceAndReload() 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)) return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
} }
@ -234,6 +278,11 @@ class TtsPlaybackManager(
val currentIdx = player.currentMediaItemIndex val currentIdx = player.currentMediaItemIndex
if (currentIdx == C.INDEX_UNSET) return if (currentIdx == C.INDEX_UNSET) return
player.pause()
_ttsState.value = _ttsState.value.copy(isLoading = true)
onResetContext()
val offset = _ttsState.value.currentWordStartOffset val offset = _ttsState.value.currentWordStartOffset
val currentChunk = textChunks.getOrNull(currentIdx) ?: return val currentChunk = textChunks.getOrNull(currentIdx) ?: return
@ -241,11 +290,14 @@ class TtsPlaybackManager(
wordTrackingJob?.cancel() wordTrackingJob?.cancel()
player.stop() player.stop()
player.clearMediaItems() player.clearMediaItems()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear() prefetchingJobs.clear()
preparationJob = scope.launch { preparationJob = scope.launch {
clearAudioFiles() clearAudioFiles()
loadedChunks.clear()
if (offset == -1) { if (offset == -1) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false) prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
@ -275,6 +327,7 @@ class TtsPlaybackManager(
private fun handleChangeTtsMode(newMode: TtsMode) { private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return if (currentTtsMode == newMode) return
currentTtsMode = newMode currentTtsMode = newMode
_ttsState.value = _ttsState.value.copy(ttsMode = newMode.name)
Timber.d("TTS Mode changed to $newMode (pending next start)") Timber.d("TTS Mode changed to $newMode (pending next start)")
} }
@ -285,12 +338,29 @@ class TtsPlaybackManager(
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
ttsMode: TtsMode, ttsMode: TtsMode,
playbackSource: String? playbackSource: String?,
args: Bundle // Added this parameter
) { ) {
if (chunks.isEmpty()) { if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.") _ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
return 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) handleStopTts(clearState = false)
textChunks = chunks textChunks = chunks
currentSpeakerId = speakerId currentSpeakerId = speakerId
@ -298,13 +368,35 @@ class TtsPlaybackManager(
this.bookTitle = bookTitle this.bookTitle = bookTitle
this.chapterTitle = chapterTitle this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri 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 { preparationJob = scope.launch {
prepareAndPlayFirstChunk() 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) { private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return if (currentSpeakerId == newSpeakerId) return
currentSpeakerId = newSpeakerId currentSpeakerId = newSpeakerId
@ -319,27 +411,52 @@ class TtsPlaybackManager(
return 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 audioFile = ttsAudioData.audioFile
val streamUri = ttsAudioData.streamUri
val serverText = ttsAudioData.serverText val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) { if ((audioFile != null || streamUri != null) && serverText != null) {
audioFiles[startAtIndex] = audioFile if (audioFile != null) {
audioFiles[startAtIndex] = audioFile
}
loadedChunks.add(startAtIndex)
val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings) val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings)
val mutableChunks = textChunks.toMutableList() val mutableChunks = textChunks.toMutableList()
mutableChunks[startAtIndex] = updatedChunk mutableChunks[startAtIndex] = updatedChunk
textChunks = mutableChunks.toList() 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) { withContext(Dispatchers.Main) {
val prepStartTime = System.currentTimeMillis()
player.setMediaItem(mediaItem) player.setMediaItem(mediaItem)
player.prepare() player.prepare()
if (startAtPosition > 0) { if (startAtPosition > 0) {
player.seekTo(startAtPosition) player.seekTo(startAtPosition)
} }
player.playWhenReady = playWhenReady player.playWhenReady = playWhenReady
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
_ttsState.value = _ttsState.value.copy( _ttsState.value = _ttsState.value.copy(
isLoading = false, isLoading = false,
isPlaying = playWhenReady, isPlaying = playWhenReady,
@ -384,6 +501,8 @@ class TtsPlaybackManager(
} }
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) { private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
onResetContext()
preparationJob?.cancel() preparationJob?.cancel()
wordTrackingJob?.cancel() wordTrackingJob?.cancel()
if (clearState) { if (clearState) {
@ -401,8 +520,11 @@ class TtsPlaybackManager(
player.stop() player.stop()
player.clearMediaItems() player.clearMediaItems()
textChunks = emptyList() textChunks = emptyList()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() } prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear() prefetchingJobs.clear()
loadedChunks.clear()
scope.launch { scope.launch {
clearAudioFiles() clearAudioFiles()
@ -411,6 +533,7 @@ class TtsPlaybackManager(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex 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 if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
@ -437,8 +560,14 @@ class TtsPlaybackManager(
val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull() val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull()
if (previousChunkIndex != null) { if (previousChunkIndex != null) {
scope.launch { scope.launch(Dispatchers.IO) {
audioFiles.remove(previousChunkIndex)?.delete() 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 _ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) { 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) 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) { 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}") _ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
} }
private fun prefetchNextChunkAudio(currentIndex: Int) { private fun prefetchNextChunkAudio(currentIndex: Int) {
for (i in 1..PREFETCH_LOOKAHEAD) { if (currentIndex == lastPrefetchIndex && prefetchLoopJob?.isActive == true) {
val targetIndex = currentIndex + i return
if (targetIndex < textChunks.size) { }
if (prefetchingJobs.containsKey(targetIndex)) { lastPrefetchIndex = currentIndex
continue
}
if (audioFiles.containsKey(targetIndex)) { prefetchLoopJob?.cancel()
continue 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 job = launch {
val nextChunk = textChunks[targetIndex] val nextChunk = textChunks[targetIndex]
val ttsAudioData = generateAudioChunk(nextChunk.text, currentSpeakerId, currentTtsMode) val prefetchStartTime = System.currentTimeMillis()
val audioFile = ttsAudioData.audioFile Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) { val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
audioFiles[targetIndex] = audioFile
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings) Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
val mutableChunks = textChunks.toMutableList()
mutableChunks[targetIndex] = updatedChunk
textChunks = mutableChunks.toList()
val nextMediaItem = createMediaItem(serverText, audioFile.absolutePath, targetIndex, updatedChunk) if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val wasLoading = _ttsState.value.isLoading _ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS")
handleStopTts(clearState = false)
var exists = false
for (k in 0 until player.mediaItemCount) {
if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) {
exists = true
break
}
} }
return@launch
}
if (!exists) { val audioFile = ttsAudioData.audioFile
var insertPosition = player.mediaItemCount 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) { for (k in 0 until player.mediaItemCount) {
val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1 if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) {
if (id > targetIndex) { exists = true
insertPosition = k
break break
} }
} }
player.addMediaItem(insertPosition, nextMediaItem)
}
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) { if (!exists) {
player.seekToNextMediaItem() var insertPosition = player.mediaItemCount
player.play() for (k in 0 until player.mediaItemCount) {
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) { val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1
_ttsState.value = _ttsState.value.copy(isLoading = false) 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
prefetchingJobs[targetIndex] = job job.invokeOnCompletion {
job.invokeOnCompletion { prefetchingJobs.remove(targetIndex)
prefetchingJobs.remove(targetIndex) }
job.join()
} }
} }
} }
} }
private suspend fun trackWordByWord() { private suspend fun trackWordByWord() {
var loopCount = 0
while (true) { while (true) {
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break
val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition } val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition }
val extras = currentMediaItem.mediaMetadata.extras ?: break if (loopCount % 20 == 0) {
val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) ?: break withContext(Dispatchers.Main) { player.playbackState }
val offsets = extras.getIntArray(KEY_WORD_OFFSETS) ?: break withContext(Dispatchers.Main) { player.isPlaying }
val sourceCfi = extras.getString("sourceCfi") ?: break }
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) { if (playbackPosition >= expectedDurationMs) {
val currentWordOffset = offsets[currentWordIndex] Timber.tag("TTS_CLOUD_DIAG").i("Stream finished naturally: pos=$playbackPosition, expected=$expectedDurationMs. Transitioning.")
if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) { withContext(Dispatchers.Main) {
_ttsState.value = _ttsState.value.copy( if (player.currentMediaItemIndex == currentIdx) {
currentWordSourceCfi = sourceCfi, if (player.hasNextMediaItem()) {
currentWordStartOffset = currentWordOffset 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 { private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
val extras = Bundle().apply { val extras = Bundle().apply {
putString("sourceCfi", chunk.sourceCfi) putString("sourceCfi", chunk.sourceCfi)
@ -615,17 +823,30 @@ class TtsPlaybackManager(
.setExtras(extras) .setExtras(extras)
.build() .build()
val uri = if (path.startsWith("ttsstream://")) path.toUri() else Uri.fromFile(File(path))
return MediaItem.Builder() return MediaItem.Builder()
.setUri(Uri.fromFile(File(path))) .setUri(uri)
.setMediaId(index.toString()) .setMediaId(index.toString())
.setMediaMetadata(metadata) .setMediaMetadata(metadata)
.build() .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() { private suspend fun clearAudioFiles() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
audioFiles.values.forEach { it.delete() } audioFiles.values.forEach { deleteTempFile(it) }
audioFiles.clear() audioFiles.clear()
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
chunkStreamIds.clear() // ADDED
loadedChunks.clear()
} }
} }
@ -640,6 +861,7 @@ class TtsPlaybackManager(
putInt("currentWordStartOffset", state.currentWordStartOffset) putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("sessionFinished", state.sessionFinished) putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource) putString("playbackSource", state.playbackSource)
putString("ttsMode", state.ttsMode)
} }
return CommandButton.Builder() return CommandButton.Builder()
.setSessionCommand(STATE_UPDATE_COMMAND) .setSessionCommand(STATE_UPDATE_COMMAND)
@ -662,4 +884,15 @@ class TtsPlaybackManager(
handleStopTts(userInitiated = true) handleStopTts(userInitiated = true)
Timber.d("TtsPlaybackManager released.") 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")
}
} }

View file

@ -23,8 +23,6 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Base64
import timber.log.Timber
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
@ -33,23 +31,33 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch 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 WordTimingInfo(val word: String, val startTime: Double)
data class TtsAudioData( data class TtsAudioData(
val audioFile: File?, val audioFile: File?,
val serverText: String?, val serverText: String?,
val wordTimings: List<WordTimingInfo>? val wordTimings: List<WordTimingInfo>?,
val error: String? = null,
val streamUri: String? = null
) )
data class PageCharacterRange( data class PageCharacterRange(
@ -59,6 +67,165 @@ data class PageCharacterRange(
val endOffset: Int val endOffset: Int
) )
class ConcurrentInputStream : java.io.InputStream() {
private val queue = java.util.concurrent.LinkedBlockingQueue<ByteArray>()
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<String, java.io.InputStream>()
private val totalBytesMap = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val finishedMap = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
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<Boolean, Long> {
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 @UnstableApi
class TtsService : MediaSessionService() { class TtsService : MediaSessionService() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@ -66,6 +233,7 @@ class TtsService : MediaSessionService() {
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var playbackManager: TtsPlaybackManager private lateinit var playbackManager: TtsPlaybackManager
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
private lateinit var cacheManager: TtsCacheManager
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -81,116 +249,317 @@ class TtsService : MediaSessionService() {
super.onUpdateNotification(session, startInForegroundRequired) super.onUpdateNotification(session, startInForegroundRequired)
} }
/** private val okHttpClient = OkHttpClient.Builder().build()
* Generic function to download TTS audio from a server endpoint. private val liveClient by lazy {
* This is used for both the self-hosted server and the Google Cloud worker. GeminiLiveClient(okHttpClient) { errorMsg ->
* if (::playbackManager.isInitialized) {
* @param chunkToSpeak The text to synthesize. playbackManager.forceStopWithError(errorMsg)
* @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<WordTimingInfo>()
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 downloadAudioChunk: suspend (String, String) -> TtsAudioData = class GeminiLiveClient(
{ chunkToSpeak, speakerId -> private val client: OkHttpClient,
downloadFromTtsServer( private val onAsyncError: (String) -> Unit = {}
chunkToSpeak, ) {
speakerId, private var webSocket: WebSocket? = null
googleCloudWorkerTtsUrl,
".mp3" private val connectionMutex = Mutex()
) private val generationMutex = Mutex()
private var clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var audioChannel = Channel<GeminiWsEvent>(Channel.UNLIMITED)
private var setupDeferred = CompletableDeferred<Boolean>().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<Boolean>()
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<Boolean>().apply { complete(false) }
StreamRegistry.clear()
}
}
private val synthesizeBaseTtsChunk: suspend (String) -> TtsAudioData = private val synthesizeBaseTtsChunk: suspend (String) -> TtsAudioData =
{ chunkToSpeak -> { chunkToSpeak ->
val (file, text) = baseTtsSynthesizer.synthesizeToFile(chunkToSpeak) val (file, text) = baseTtsSynthesizer.synthesizeToFile(chunkToSpeak)
TtsAudioData(file, text, null) TtsAudioData(file, text, null)
} }
private val audioGenerator: suspend (text: String, speaker: String, mode: TtsMode) -> TtsAudioData = val audioGenerator: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, text: String, speaker: String, mode: TtsMode, authToken: String?) -> TtsAudioData =
{ text, speaker, mode -> { bookTitle, chapterTitle, chunkIndex, totalChunks, text, speaker, mode, authToken ->
cacheManager.saveTotalChunks(bookTitle, chapterTitle, totalChunks)
when (mode) { 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) TtsMode.BASE -> synthesizeBaseTtsChunk(text)
} }
} }
@ -199,6 +568,8 @@ class TtsService : MediaSessionService() {
super.onCreate() super.onCreate()
Timber.d("TtsService created.") Timber.d("TtsService created.")
cacheManager = TtsCacheManager(this)
baseTtsSynthesizer = BaseTtsSynthesizer(this) baseTtsSynthesizer = BaseTtsSynthesizer(this)
scope.launch { scope.launch {
try { try {
@ -213,14 +584,49 @@ class TtsService : MediaSessionService() {
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.build() .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) player = ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true) .setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
.build() .build()
playbackManager = TtsPlaybackManager( playbackManager = TtsPlaybackManager(
player = player, player = player,
generateAudioChunk = audioGenerator generateAudioChunk = audioGenerator,
onResetContext = { liveClient.close() }
) )
mediaSession = MediaSession.Builder(this, player) mediaSession = MediaSession.Builder(this, player)

View file

@ -21,36 +21,189 @@ package com.aryan.reader.tts
import android.content.Context import android.content.Context
import android.media.MediaPlayer import android.media.MediaPlayer
import timber.log.Timber import androidx.annotation.OptIn
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import timber.log.Timber
import java.net.HttpURLConnection import java.io.File
import java.net.URL 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 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 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 GEMINI_TTS_SPEAKERS = listOf(
val GOOGLE_TTS_SPEAKERS = listOf( GeminiVoice("Zephyr", "Zephyr", "Bright, Higher pitch"),
"US Female: F" to "en-US-Standard-F", GeminiVoice("Puck", "Puck", "Upbeat, Middle pitch"),
"US Female: H" to "en-US-Standard-H", GeminiVoice("Charon", "Charon", "Informative, Lower pitch"),
"US Male: I" to "en-US-Standard-I", GeminiVoice("Kore", "Kore", "Firm, Middle pitch"),
"US Male: J" to "en-US-Standard-J" 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<File> = 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<TtsChapterCacheInfo> {
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<File>, 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<String> { fun splitTextIntoChunks(text: String, maxLengthPerChunk: Int = TTS_CHUNK_MAX_LENGTH): List<String> {
if (text.isBlank()) return emptyList() if (text.isBlank()) return emptyList()
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""") val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
@ -88,31 +241,44 @@ fun splitTextIntoChunks(text: String, maxLengthPerChunk: Int = TTS_CHUNK_MAX_LEN
return chunks return chunks
} }
@UnstableApi
class SpeakerSamplePlayer( class SpeakerSamplePlayer(
private val context: Context, private val context: Context,
private val scope: CoroutineScope private val scope: CoroutineScope,
private val getAuthToken: suspend () -> String?
) { ) {
private val sampleMediaPlayer = MediaPlayer() private val sampleMediaPlayer = MediaPlayer()
var loadingSpeakerId by mutableStateOf<String?>(null) var loadingSpeakerId by mutableStateOf<String?>(null)
var playingSpeakerId by mutableStateOf<String?>(null) var playingSpeakerId by mutableStateOf<String?>(null)
val cachedSpeakers = androidx.compose.runtime.mutableStateListOf<String>()
private val httpClient = okhttp3.OkHttpClient()
@OptIn(UnstableApi::class)
private val liveClient = TtsService.GeminiLiveClient(httpClient)
init { 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 -> sampleMediaPlayer.setOnErrorListener { mp, what, extra ->
Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.") Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.")
playingSpeakerId = null playingSpeakerId = null
loadingSpeakerId = null loadingSpeakerId = null
try { try { mp.reset() } catch (_: Exception) {}
mp.reset()
} catch (e: IllegalStateException) {
Timber.e("Error resetting MediaPlayer: ${e.message}")
}
true true
} }
} }
@Suppress("unused")
fun playOrStop(speakerId: String) { fun playOrStop(speakerId: String) {
scope.launch { scope.launch {
liveClient.close()
when { when {
playingSpeakerId == speakerId -> { playingSpeakerId == speakerId -> {
sampleMediaPlayer.stop() sampleMediaPlayer.stop()
@ -122,51 +288,49 @@ class SpeakerSamplePlayer(
loadingSpeakerId == speakerId -> { loadingSpeakerId == speakerId -> {
loadingSpeakerId = null loadingSpeakerId = null
} }
else -> playSample(speakerId) else -> {
playSample(speakerId)
}
} }
} }
} }
@OptIn(UnstableApi::class)
private suspend fun playSample(speakerId: String) { private suspend fun playSample(speakerId: String) {
if (sampleMediaPlayer.isPlaying) { if (sampleMediaPlayer.isPlaying) sampleMediaPlayer.stop()
sampleMediaPlayer.stop()
}
sampleMediaPlayer.reset() sampleMediaPlayer.reset()
loadingSpeakerId = speakerId loadingSpeakerId = speakerId
playingSpeakerId = null playingSpeakerId = null
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val cacheFile = File(context.cacheDir, "sample_$speakerId.wav")
try { try {
val url = URL(googleCloudWorkerTtsUrl) if (!cacheFile.exists()) {
val connection = url.openConnection() as HttpURLConnection val bucketName = "reader-9fc469d7.firebasestorage.app"
connection.requestMethod = "POST" val sampleUrl = "https://firebasestorage.googleapis.com/v0/b/$bucketName/o/samples%2Fsample_${speakerId}.wav?alt=media"
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
val jsonPayload = JSONObject().apply { val request = okhttp3.Request.Builder()
put("text", TTS_SAMPLE_TEXT) .url(sampleUrl)
put("speaker", speakerId) .build()
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
val response = httpClient.newCall(request).execute()
if (connection.responseCode == HttpURLConnection.HTTP_OK) { if (response.isSuccessful) {
val responseBody = connection.inputStream.bufferedReader().use { it.readText() } response.body?.byteStream()?.use { input ->
val audioBase64 = JSONObject(responseBody).getString("audio_base64") cacheFile.outputStream().use { output ->
input.copyTo(output)
val dataUri = "data:audio/mpeg;base64,$audioBase64" }
withContext(Dispatchers.Main) {
if (loadingSpeakerId != speakerId) {
return@withContext
} }
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 -> sampleMediaPlayer.setOnPreparedListener { mp ->
if (loadingSpeakerId == speakerId) { if (loadingSpeakerId == speakerId) {
mp.start() mp.start()
@ -180,16 +344,54 @@ class SpeakerSamplePlayer(
sampleMediaPlayer.prepareAsync() sampleMediaPlayer.prepareAsync()
} }
} else { } else {
Timber.e("Failed to fetch sample for $speakerId. Code: ${connection.responseCode}") throw Exception("Sample file missing after download attempt")
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
} }
} catch (e: Exception) { } 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 } 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() { fun release() {
sampleMediaPlayer.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()
} }

View file

@ -245,19 +245,19 @@
<string name="feature_dict">Basic Dictionary</string> <string name="feature_dict">Basic Dictionary</string>
<string name="feature_dict_desc">Look up single words quickly</string> <string name="feature_dict_desc">Look up single words quickly</string>
<string name="current_plan">Current Plan</string> <string name="current_plan">Current Plan</string>
<!-- Promotional badge displayed on the Pro plan card. %% is a literal percent sign. --> <!-- Promotional badge displayed on the Pro plan card. % is a literal percent sign. -->
<string name="pro_sale_off" translatable="false">50%% OFF</string> <string name="pro_sale_off" translatable="false">50% OFF</string>
<string name="loading_price">Loading price…</string> <string name="loading_price">Loading price…</string>
<string name="one_time_payment">One-time payment</string> <string name="one_time_payment">One-time payment</string>
<string name="lifetime_access">Lifetime Access</string> <string name="lifetime_access">Lifetime Access</string>
<string name="early_access_sale">Early Access Sale</string> <string name="early_access_sale">Early Access Sale</string>
<string name="pro_includes">Everything in Free, plus:</string> <string name="pro_includes">Features:</string>
<string name="feature_cloud_sync">Cloud Sync Across Devices</string> <string name="feature_cloud_sync">Cloud Sync Across Devices</string>
<!-- "Google Drive" is a brand name — do not translate. --> <!-- "Google Drive" is a brand name — do not translate. -->
<string name="feature_cloud_sync_desc">Keep your entire library, including book files and reading progress, synced across up to 4 devices.</string> <string name="feature_cloud_sync_desc">Keep your entire library, including book files and reading progress, synced across up to 4 devices.</string>
<!-- "Summarization" is a Pro AI feature name. --> <!-- "Summarization" is a Pro AI feature name. -->
<string name="feature_summarize">Summarization</string> <string name="feature_summarize">Summarization</string>
<string name="feature_summarize_desc">Get quick summaries of chapters or pages</string> <string name="feature_summarize_desc">Get 10 free summaries of chapters or pages per day</string>
<!-- "Smart Dictionary" is a Pro AI feature name. --> <!-- "Smart Dictionary" is a Pro AI feature name. -->
<string name="feature_smart_dict">Smart Dictionary</string> <string name="feature_smart_dict">Smart Dictionary</string>
<string name="feature_smart_dict_desc">Search phrases and even paragraphs, not just single words</string> <string name="feature_smart_dict_desc">Search phrases and even paragraphs, not just single words</string>
@ -272,6 +272,7 @@
<string name="upgrade_unavailable">Upgrade currently unavailable. Please check your internet and try again.</string> <string name="upgrade_unavailable">Upgrade currently unavailable. Please check your internet and try again.</string>
<!-- "Google" is a brand name. "Episteme Pro" is the product tier name — do not translate "Episteme". --> <!-- "Google" is a brand name. "Episteme Pro" is the product tier name — do not translate "Episteme". -->
<string name="sign_in_to_purchase">Please sign in to your Google account to purchase Episteme Pro.</string> <string name="sign_in_to_purchase">Please sign in to your Google account to purchase Episteme Pro.</string>
<string name="sign_in_to_purchase_credits">Please sign in to your Google account to purchase credits.</string>
<string name="verifying_purchase_desc">This may take a few moments. Your Pro status will be updated automatically.</string> <string name="verifying_purchase_desc">This may take a few moments. Your Pro status will be updated automatically.</string>
<!-- "Pro" refers to the Episteme Pro product tier. --> <!-- "Pro" refers to the Episteme Pro product tier. -->
<string name="dialog_existing_purchase_desc">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.</string> <string name="dialog_existing_purchase_desc">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.</string>
@ -511,7 +512,7 @@
<string name="search_in_book">Search in book…</string> <string name="search_in_book">Search in book…</string>
<string name="search_no_results_simple">No results found.</string> <string name="search_no_results_simple">No results found.</string>
<!-- Common.kt: SummarizationPopup — AI feature, not translated. --> <!-- Common.kt: — AI feature, not translated. -->
<string name="generating_summary" translatable="false">Generating summary…</string> <string name="generating_summary" translatable="false">Generating summary…</string>
<string name="action_stop">Stop</string> <string name="action_stop">Stop</string>
<string name="action_read_aloud">Read aloud</string> <string name="action_read_aloud">Read aloud</string>

View file

@ -22,4 +22,6 @@ class AuthRepository(private val applicationContext: Context) {
fun observeAuthState(): Flow<UserData?> { fun observeAuthState(): Flow<UserData?> {
return flowOf(null) return flowOf(null)
} }
suspend fun getIdToken(): String? = null
} }

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
data class ProUpgradeState( data class ProUpgradeState(
val productDetails: ProductDetailsEntity? = null, val productDetails: ProductDetailsEntity? = null,
val creditProducts: List<ProductDetailsEntity> = emptyList(),
val hasValidPurchase: Boolean = false, val hasValidPurchase: Boolean = false,
val activePurchases: List<PurchaseEntity> = emptyList(), val activePurchases: List<PurchaseEntity> = emptyList(),
val billingClientReady: Boolean = false, val billingClientReady: Boolean = false,
@ -34,9 +35,10 @@ class BillingClientWrapper(
// No-op // 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") _proUpgradeState.value = _proUpgradeState.value.copy(error = "Not available in Open Source version")
} }
fun consumePurchase(purchaseToken: String) {}
fun clearError() { fun clearError() {
_proUpgradeState.value = _proUpgradeState.value.copy(error = null) _proUpgradeState.value = _proUpgradeState.value.copy(error = null)

View file

@ -6,7 +6,8 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class PurchaseVerificationRequest( data class PurchaseVerificationRequest(
val purchaseToken: String, val purchaseToken: String,
val idToken: String val idToken: String,
val productId: String
) )
@Serializable @Serializable
@ -16,7 +17,7 @@ data class VerificationResponse(
) )
class CloudflareRepository { class CloudflareRepository {
suspend fun verifyPurchase(purchaseToken: String): Result<VerificationResponse> { suspend fun verifyPurchase(purchaseToken: String, productId: String): Result<VerificationResponse> {
return Result.failure(Exception("Not available in OSS version")) return Result.failure(Exception("Not available in OSS version"))
} }
} }

View file

@ -80,9 +80,8 @@ class FirestoreRepository {
// No-op // No-op
} }
fun listenToUserProfile(userId: String, onUpdate: (isPro: Boolean) -> Unit): Any? { fun listenToUserProfile(userId: String, onUpdate: (isPro: Boolean, credits: Int) -> Unit): Any? {
// In OSS, user is never Pro. Return null as the "listener" onUpdate(false, 0)
onUpdate(false)
return null return null
} }