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"
minSdk = 26
targetSdk = 35
versionCode = 43
versionName = "1.0.42"
versionCode = 44
versionName = "1.0.43"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {

View file

@ -584,22 +584,19 @@
if (clientHeight === 0) return;
var activeFragment = null;
var hasFoundAnyElementInDom = false;
var hasFoundVisible = false;
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;
for (var i = 0; i < window.TOC_FRAGMENTS.length; i++) {
var id = window.TOC_FRAGMENTS[i];
// FIX: Look for both 'id' and 'name' attributes
var el = document.getElementById(id) || document.querySelector('[name="' + id + '"]');
if (el) {
hasFoundVisible = true;
var rect = el.getBoundingClientRect();
// Log individual element positions so we can see them in your FRAG_NAV_DEBUG filter
console.log("FRAG_NAV_DEBUG: Checking #" + id + " | rect.top: " + Math.round(rect.top) + " | threshold: " + threshold);
if (rect.top <= threshold) {
@ -660,7 +657,23 @@
);
};
window.addEventListener("scroll", window.reportScrollState, { passive: true });
let scrollThrottleTimeout = null;
let lastScrollTime = 0;
window.addEventListener("scroll", function() {
const now = Date.now();
if (now - lastScrollTime >= 100) {
window.reportScrollState();
lastScrollTime = now;
} else {
if (scrollThrottleTimeout) clearTimeout(scrollThrottleTimeout);
scrollThrottleTimeout = setTimeout(function() {
window.reportScrollState();
lastScrollTime = Date.now();
}, 100);
}
}, { passive: true });
window.addEventListener("resize", window.reportScrollState);
window.triggerInitialScrollStateReport = function () {
@ -896,12 +909,8 @@
}
try {
console.log(`$ {
TTS_HIGHLIGHT_LOG_TAG
}
: Resolving CFI to node...`);
const location = window.getNodeAndOffsetFromCfi(cfi);
console.log(`${TTS_HIGHLIGHT_LOG_TAG}: Resolving CFI to node...`);
const location = window.getNodeAndOffsetFromCfi(cfi, true);
if (!location || !location.node) {
const errorMsg = "JS: Could not find node for CFI.";
@ -1404,7 +1413,7 @@
`);
}
function resolveCfiPath(rootElement, path) {
function resolveCfiPath(rootElement, path, requestChunkIfMissing = false) {
let currentNode = rootElement;
const steps = path.substring(1).split("/").map(Number);
@ -1420,13 +1429,27 @@
let chunkElement = currentNode.querySelector(`.chunk-container[data-chunk-index="${chunkIndex}"]`);
if (chunkElement) {
if (chunkElement.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
if (chunkElement.innerHTML === "") {
if (window.virtualization && window.virtualization.chunksData[chunkIndex]) {
console.log("CFI_DIAGNOSIS: Chunk " + chunkIndex + " was empty, restoring content for CFI resolution.");
chunkElement.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkElement.style.height = "";
if (window.CURRENT_HIGHLIGHTS) {
window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);
}
} else {
if (requestChunkIfMissing) {
console.log("PosSaveDiag: Requesting missing chunk " + chunkIndex + " for CFI resolution.");
if (window.ContentBridge && window.ContentBridge.requestChunk) {
if (!window._requestedChunksForCfi) window._requestedChunksForCfi = {};
if (!window._requestedChunksForCfi[chunkIndex]) {
window._requestedChunksForCfi[chunkIndex] = true;
window.ContentBridge.requestChunk(chunkIndex);
}
}
}
return null;
}
}
let elementsInChunk = Array.from(chunkElement.childNodes).filter(n => n.nodeType === Node.ELEMENT_NODE);
@ -1450,7 +1473,7 @@
return currentNode;
}
window.getNodeAndOffsetFromCfi = function (cfi) {
window.getNodeAndOffsetFromCfi = function (cfi, requestChunkIfMissing = false) {
try {
var pathParts = cfi.split(":");
var nodePath = pathParts[0];
@ -1470,7 +1493,7 @@
return { node: cfiRoot, offset: charOffset };
}
let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve);
let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve, requestChunkIfMissing);
if (!resolvedNode) return null;
@ -1650,6 +1673,7 @@
}
console.log("NavDiag: JS scrollToCfi called with cleanCfi=" + cleanCfi);
window._requestedChunksForCfi = {}; // Reset the requested cache
if (!cleanCfi || !cleanCfi.startsWith('/')) {
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
@ -1659,7 +1683,7 @@
}
let attempts = 0;
const maxAttempts = 20;
const maxAttempts = 40; // Increased to 40 attempts (4 seconds) to give Kotlin time to inject chunks
let stabilizingFrames = 0;
const maxStabilizingFrames = 8;
@ -1667,7 +1691,8 @@
attempts++;
try {
const location = window.getNodeAndOffsetFromCfi(cleanCfi);
// Pass 'true' to dynamically request any missing chunks needed to resolve the position
const location = window.getNodeAndOffsetFromCfi(cleanCfi, true);
if (location && location.node) {
if (!document.body.contains(location.node)) {
@ -1700,9 +1725,24 @@
const range = document.createRange();
const validOffset = Math.min(remainingOffset, currentNode.nodeValue.length);
const endOffset = Math.min(validOffset + 1, currentNode.nodeValue.length);
if (validOffset < endOffset) {
range.setStart(currentNode, validOffset);
range.setEnd(currentNode, endOffset);
} else if (validOffset > 0) {
range.setStart(currentNode, validOffset - 1);
range.setEnd(currentNode, validOffset);
} else {
range.setStart(currentNode, validOffset);
range.collapse(true);
const rect = range.getBoundingClientRect();
}
let rect = range.getBoundingClientRect();
const rects = range.getClientRects();
if (rects && rects.length > 0) {
rect = rects[0];
}
if (rect.top !== 0 || rect.bottom !== 0) {
targetScrollY = window.scrollY + rect.top - (window.VIEWPORT_PADDING_TOP + 5);
@ -1739,12 +1779,14 @@
if (attempts < maxAttempts) {
setTimeout(attemptScroll, 100);
} else {
console.log("PosSaveDiag: attemptScroll failed after " + maxAttempts + " attempts for CFI: " + cleanCfi);
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
window.CfiBridge.onScrollFinished(false);
}
}
}
} catch (e) {
console.log("PosSaveDiag: attemptScroll exception: " + e.message);
if (window.CfiBridge && window.CfiBridge.onScrollFinished) {
window.CfiBridge.onScrollFinished(false);
}

View file

@ -72,6 +72,7 @@ fun AppNavigation(
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
try {
when (uiState.selectedFileType) {
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) {
@ -92,11 +93,15 @@ fun AppNavigation(
}
}
null -> {
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
} catch (e: IllegalStateException) {
Timber.w(e, "Navigation transition already in progress, ignoring.")
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1039,6 +1039,19 @@ private fun AppDrawerContent(
uiState.currentUser.email?.let { email ->
Text(text = email, style = MaterialTheme.typography.bodyMedium)
}
if (BuildConfig.FLAVOR == "pro") {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = CircleShape,
modifier = Modifier.padding(top = 8.dp)
) {
Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.FormatListNumbered, contentDescription = "Credits", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer)
Spacer(modifier = Modifier.width(4.dp))
Text("${uiState.credits} Credits", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer)
}
}
}
}
} else {
// Signed-out: Show Sign In button at the top

View file

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

View file

@ -61,10 +61,12 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aryan.reader.data.ProductDetailsEntity
import kotlinx.coroutines.launch
import java.text.NumberFormat
import java.util.Currency
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ProScreen(
@ -75,11 +77,12 @@ fun ProScreen(
val proUpgradeState by viewModel.proUpgradeState.collectAsState()
val uiState by viewModel.uiState.collectAsState()
var showExistingPurchaseDialog by remember { mutableStateOf(false) }
var showEarlyAccessInfoDialog by remember { mutableStateOf(false) }
var showSignInRequiredDialog by remember { mutableStateOf(false) }
val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 })
var selectedTabIndex by remember { mutableIntStateOf(1) }
// Removed Free Tab, so tabCount is max 2
val tabCount = if (BuildConfig.FLAVOR == "pro") 2 else 1
val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabCount })
var selectedTabIndex by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) {
@ -92,8 +95,9 @@ fun ProScreen(
}
}
// Default to the Credits tab if they already own Pro
LaunchedEffect(uiState.isProUser) {
if (uiState.isProUser) {
if (uiState.isProUser && BuildConfig.FLAVOR == "pro") {
selectedTabIndex = 1
}
}
@ -109,10 +113,6 @@ fun ProScreen(
ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false })
}
if (showEarlyAccessInfoDialog) {
EarlyAccessInfoDialog(onDismiss = { showEarlyAccessInfoDialog = false })
}
if (showSignInRequiredDialog) {
SignInRequiredDialog(
onSignInClick = {
@ -130,7 +130,7 @@ fun ProScreen(
Scaffold(
topBar = {
TopAppBar(
title = { }, // Removed header content
title = { },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@ -170,48 +170,23 @@ fun ProScreen(
.background(
if (selectedTabIndex == 0) MaterialTheme.colorScheme.surface else Color.Transparent
)
.border( // Border for selected Free tab
.border(
width = if (selectedTabIndex == 0) 2.dp else 0.dp,
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
),
text = {
AutoSizeText(stringResource(R.string.tab_free),
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
)
},
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
modifier = Modifier
.height(56.dp)
.clip(CircleShape)
.background(
if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent
)
.border( // Border for selected Pro tab
width = if (selectedTabIndex == 1) 2.dp else 0.dp,
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
),
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.crown),
contentDescription = "Pro",
modifier = Modifier.size(16.dp),
tint = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
tint = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
AutoSizeText(stringResource(R.string.drawer_pro_unlocked),
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
)
@ -220,6 +195,31 @@ fun ProScreen(
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
if (BuildConfig.FLAVOR == "pro") {
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
modifier = Modifier
.height(56.dp)
.clip(CircleShape)
.background(if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent)
.border(
width = if (selectedTabIndex == 1) 2.dp else 0.dp,
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
),
text = {
AutoSizeText("Credits",
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
)
},
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@ -229,9 +229,8 @@ fun ProScreen(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
userScrollEnabled = true
) { page ->
if (page == 0) {
FreeTierCard()
} else {
when (page) {
0 -> {
ProTierCard(
isProUser = uiState.isProUser,
isUserSignedIn = uiState.currentUser != null,
@ -242,83 +241,23 @@ fun ProScreen(
}
},
onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true },
onShowEarlyAccessInfo = { showEarlyAccessInfoDialog = true },
onSignInRequiredClick = { showSignInRequiredDialog = true }
)
onSignInRequiredClick = { showSignInRequiredDialog = true })
}
}
}
}
}
@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)
)
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) }
})
}
}
}
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)
}
}
}
@ -331,7 +270,6 @@ private fun ProTierCard(
proUpgradeState: ProUpgradeState,
onUpgradeClick: () -> Unit,
onShowExistingPurchaseDialog: () -> Unit,
onShowEarlyAccessInfo: () -> Unit,
onSignInRequiredClick: () -> Unit
) {
val productDetails = proUpgradeState.productDetails
@ -431,27 +369,6 @@ private fun ProTierCard(
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = onShowEarlyAccessInfo,
modifier = Modifier
.height(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary
),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.early_access_sale), style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(16.dp))
}
@ -678,19 +595,6 @@ fun ExistingPurchaseDialog(onDismiss: () -> Unit) {
)
}
@Composable
fun EarlyAccessInfoDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text(stringResource(R.string.early_access_sale)) },
text = { Text(stringResource(R.string.dialog_early_access_desc)) },
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_got_it)) }
}
)
}
@Composable
fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
@ -706,3 +610,144 @@ fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
}
)
}
@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)
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>>
@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<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileEntity>
@ -46,8 +46,8 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileEntity>
@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<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
suspend fun deleteFilePermanently(bookIds: List<String>)

View file

@ -53,3 +53,28 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val highlights: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long
)
data class RecentFileSummary(
val bookId: String,
val uriString: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String?,
val title: String?,
val author: String?,
@ColumnInfo(name = "lastChapterIndex") val lastChapterIndex: Int?,
val lastPage: Int?,
@ColumnInfo(name = "lastPositionCfi") val lastPositionCfi: String?,
@ColumnInfo(name = "progressPercentage") val progressPercentage: Float?,
@ColumnInfo(defaultValue = "1") val isRecent: Boolean,
@ColumnInfo(defaultValue = "1") val isAvailable: Boolean,
val lastModifiedTimestamp: Long,
@ColumnInfo(defaultValue = "0") val isDeleted: Boolean,
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?,
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long
)

View file

@ -158,3 +158,32 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
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,23 +360,28 @@ class RecentFilesRepository(private val context: Context) {
}
suspend fun markAsNotRecent(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Marking ${bookIds.size} items as not recent.")
recentFileDao.markAsNotRecent(bookIds, System.currentTimeMillis())
bookIds.chunked(900).forEach { chunk ->
if (chunk.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Marking ${chunk.size} items as not recent.")
recentFileDao.markAsNotRecent(chunk, System.currentTimeMillis())
}
}
}
suspend fun markAsDeleted(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
recentFileDao.markAsDeleted(bookIds, System.currentTimeMillis())
Timber.d("DeleteDebug: DAO - Marked ${bookIds.size} items as deleted.")
bookIds.chunked(900).forEach { chunk ->
if (chunk.isNotEmpty()) {
recentFileDao.markAsDeleted(chunk, System.currentTimeMillis())
Timber.d("DeleteDebug: DAO - Marked ${chunk.size} items as deleted.")
}
}
}
suspend fun deleteFilePermanently(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isEmpty()) return@withContext
val itemsToRemove = bookIds.mapNotNull { recentFileDao.getFileByBookId(it) }
bookIds.chunked(900).forEach { chunk ->
val itemsToRemove = chunk.mapNotNull { recentFileDao.getFileByBookId(it) }
if (itemsToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
@ -407,6 +412,7 @@ class RecentFilesRepository(private val context: Context) {
Timber.w("DeleteDebug: DAO - Files not found for permanent deletion.")
}
}
}
private fun getCoverCacheDirInternal(): File {
if (!coverCacheDir.exists()) {
@ -490,8 +496,10 @@ class RecentFilesRepository(private val context: Context) {
suspend fun addRecentFiles(items: List<RecentFileItem>) = withContext(Dispatchers.IO) {
if (items.isEmpty()) return@withContext
val entities = items.map { it.toRecentFileEntity() }
items.chunked(900).forEach { chunk ->
val entities = chunk.map { it.toRecentFileEntity() }
recentFileDao.insertOrUpdateFiles(entities)
}
Timber.d("Batch inserted/updated ${items.size} recent files in DB.")
}
}

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

View file

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

View file

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

View file

@ -62,9 +62,9 @@ suspend fun loadChapterContent(
val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
val bodyChildren = doc.body().children().toList()
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val bodyNodes = doc.body().childNodes().toList()
val chunkedList = bodyNodes.chunked(20).map { chunkOfNodes ->
chunkOfNodes.joinToString(separator = "\n") { it.outerHtml() }
}
if (chunkedList.isEmpty()) {
head to listOf("<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.Canvas
import android.os.Build
import android.speech.tts.TextToSpeech
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
@ -87,9 +86,7 @@ import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -101,14 +98,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@ -140,6 +134,7 @@ import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -188,7 +183,6 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
@ -468,9 +462,10 @@ fun EpubReaderTopBar(
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
onOpenTtsSettings()
},
leadingIcon = {
Icon(
@ -478,21 +473,6 @@ fun EpubReaderTopBar(
contentDescription = null,
modifier = Modifier.size(20.dp)
)
})
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.text_to_speech),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
@ -502,7 +482,6 @@ fun EpubReaderTopBar(
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@ -514,15 +493,12 @@ fun EpubReaderBottomBar(
ttsState: TtsState,
isProUser: Boolean,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
onOpenTtsControls: () -> Unit,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onSummarize: () -> Unit,
onRecap: () -> Unit,
onOpenAiHub: () -> Unit,
onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit,
hiddenTools: Set<String>,
modifier: Modifier = Modifier
) {
@ -600,43 +576,20 @@ fun EpubReaderBottomBar(
"KotlinConstantConditions",
"SimplifyBooleanWithConstants"
) if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }) {
onClick = onOpenAiHub
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = "AI Features"
)
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
onClick = {
showAiFeaturesMenu = false
onSummarize()
})
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_recap_beta)) },
onClick = {
showAiFeaturesMenu = false
onRecap()
})
}
}
}
}
}
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
@ -650,40 +603,9 @@ fun EpubReaderBottomBar(
),
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)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsControlsSheet(
onDismiss: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
ttsController: com.aryan.reader.tts.TtsController
fun TtsOverlayControls(
ttsController: com.aryan.reader.tts.TtsController,
ttsState: TtsState,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
credits: Int
) {
val context = androidx.compose.ui.platform.LocalContext.current
val ttsState by ttsController.ttsState.collectAsState()
// Local TTS for Sample Playback
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
var isTtsReady by remember { mutableStateOf(false) }
var rate by remember { mutableFloatStateOf(loadTtsSpeechRate(context)) }
var pitch by remember { mutableFloatStateOf(loadTtsPitch(context)) }
var isDraggingRate by remember { mutableStateOf(false) }
var isDraggingPitch by remember { mutableStateOf(false) }
// Initialize Local TTS for samples
DisposableEffect(Unit) {
val instance = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
isTtsReady = true
try {
val preferredVoiceName = loadNativeVoice(context)
if (preferredVoiceName != null) {
tts?.voices?.find { it.name == preferredVoiceName }?.let { targetVoice ->
tts?.voice = targetVoice
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to apply preferred voice in sample")
}
}
}
tts = instance
onDispose { instance.shutdown() }
}
val activeMode = try { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.valueOf(ttsState.ttsMode) } catch(_: Exception) { com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD }
val saveAndSlice = {
val saveAndApply = {
saveTtsSpeechRate(context, rate)
saveTtsPitch(context, pitch)
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
ttsController.setPlaybackParameters(rate, pitch)
} else {
ttsController.sliceAndRetainPosition()
}
}
val ttsSample = stringResource(R.string.tts_sample_text)
val backgroundAlpha = 0.6f
ModalBottomSheet(
onDismissRequest = onDismiss,
contentWindowInsets = { WindowInsets.navigationBars }
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = modifier.widthIn(max = 400.dp).animateContentSize()
) {
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
Text(stringResource(R.string.tts_voice_adjustments), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
AnimatedContent(
targetState = isCollapsed,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
) { collapsed ->
if (collapsed) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
modifier = Modifier.size(36.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
"Play/Pause",
modifier = Modifier.size(20.dp)
)
}
if (ttsState.isLoading) CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
strokeWidth = 2.dp
)
}
}
} 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)
)
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
shape = RoundedCornerShape(8.dp)
) {
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
Text(
voiceName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).widthIn(max = 100.dp)
)
}
if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f),
shape = RoundedCornerShape(8.dp)
) {
Text(
"$credits",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Stop TTS", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
}
}
Spacer(Modifier.height(16.dp))
// Rate Slider
// 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(stringResource(R.string.tts_speed_label, "%.1f".format(rate)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
rate = 1.0f
ttsController.pause()
saveAndSlice()
}) {
Icon(Icons.Default.Refresh, contentDescription = "Reset Speed")
}
}
Text("Spd: %.1fx".format(rate), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
Slider(
value = rate,
onValueChange = {
rate = it
// Pause playback immediately when user starts dragging
if (!isDraggingRate) {
isDraggingRate = true
ttsController.pause()
rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingRate = true; ttsController.pause()
}
},
onValueChangeFinished = {
isDraggingRate = false
saveAndSlice()
},
onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
valueRange = 0.5f..3.0f,
steps = 24 // Creates 0.1 increments
steps = 24,
modifier = Modifier.weight(1f).height(24.dp)
)
// Pitch Slider
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(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")
}
}
Text("Ptch: %.1fx".format(pitch), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
Slider(
value = pitch,
onValueChange = {
pitch = it
if (!isDraggingPitch) {
isDraggingPitch = true
ttsController.pause()
pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
isDraggingPitch = true; ttsController.pause()
}
},
onValueChangeFinished = {
isDraggingPitch = false
saveAndSlice()
},
onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
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) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
strokeWidth = 3.dp
)
} else {
Icon(
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) stringResource(R.string.tts_pause_book) else stringResource(R.string.tts_resume_book),
modifier = Modifier.size(32.dp)
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))
}
}
}
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
)
}
}
Spacer(Modifier.height(24.dp))
OutlinedButton(
onClick = {
onDismiss()
onOpenDeviceVoiceSettings()
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Settings, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.tts_system_settings))
}
}
}

View file

@ -354,10 +354,69 @@ private fun ChaptersList(
result
}
Box(modifier = Modifier.fillMaxSize()) {
val coroutineScope = rememberCoroutineScope()
val activeTocEntry = remember(effectiveToc, currentChapterPath, activeFragmentId, firstEntryForCurrentChapter) {
effectiveToc.find {
it.absolutePath == currentChapterPath && it.fragmentId == activeFragmentId
} ?: firstEntryForCurrentChapter
}
val onScrollToCurrent = {
coroutineScope.launch {
val targetEntry = activeTocEntry ?: return@launch
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
if (targetOriginalIndex != -1) {
// Ensure parents are expanded
var currentLevel = targetEntry.depth
val newExpanded = expandedEntryIndices.toMutableSet()
for (i in targetOriginalIndex downTo 0) {
val entry = effectiveToc[i]
if (entry.depth < currentLevel) {
newExpanded.add(i)
currentLevel = entry.depth
}
}
expandedEntryIndices = newExpanded
// Delay to allow visibility array to recompose
kotlinx.coroutines.delay(100)
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
if (visibleIdx != -1) {
listState.animateScrollToItem(visibleIdx)
}
}
}
Unit
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = { expandedEntryIndices = effectiveToc.indices.toSet() }) {
Text("Expand All")
}
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
Text("Collapse All")
}
TextButton(onClick = onScrollToCurrent) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxHeight().padding(end = 12.dp)
modifier = Modifier
.fillMaxHeight()
.padding(end = 12.dp)
) {
items(
items = visibleItemInfo,
@ -367,22 +426,12 @@ private fun ChaptersList(
val hasChildren = nextItem != null && nextItem.depth > entry.depth
val isExpanded = expandedEntryIndices.contains(originalIndex)
// HIGHLIGHT LOGIC FIXED
val isCurrentPath = currentChapterPath == entry.absolutePath
val matchesFragment = entry.fragmentId == activeFragmentId
// Fallback logic
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter
val isHighlighting = isCurrentPath && (matchesFragment || isFallback)
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback")
}
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting")
}
TocTreeItem(
label = entry.label,
depth = entry.depth,
@ -412,6 +461,7 @@ private fun ChaptersList(
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
@Composable

View file

@ -27,6 +27,8 @@ package com.aryan.reader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
@ -128,6 +130,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -147,7 +150,6 @@ import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderThemePanel
@ -180,6 +182,7 @@ import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
@ -416,6 +419,7 @@ fun EpubReaderScreen(
initialBookmarksJson = initialBookmarksJson,
initialHighlightsJson = uiState.initialHighlightsJson,
isProUser = isProUser,
credits = uiState.credits,
onNavigateBack = onNavigateBack,
onSavePosition = onSavePosition,
onBookmarksChanged = onBookmarksChanged,
@ -438,7 +442,8 @@ fun EpubReaderScreen(
}
}
}
} else null
} else null,
viewModel = viewModel
)
}
@ -456,6 +461,7 @@ fun EpubReaderHost(
initialBookmarksJson: String?,
initialHighlightsJson: String?,
isProUser: Boolean,
credits: Int,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -466,7 +472,8 @@ fun EpubReaderHost(
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null
onDeleteReflow: (() -> Unit)? = null,
viewModel: MainViewModel
) {
val view = LocalView.current
val context = LocalContext.current
@ -479,6 +486,7 @@ fun EpubReaderHost(
val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
@ -516,7 +524,13 @@ fun EpubReaderHost(
mutableStateOf(loadPageTurnAnimationSetting(context))
}
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var currentTtsMode by remember {
mutableStateOf(
loadTtsMode(context).let {
if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
}
)
}
val locatorConverter = remember(context) {
LocatorConverter(
@ -546,6 +560,7 @@ fun EpubReaderHost(
}
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
@ -679,24 +694,35 @@ fun EpubReaderHost(
if (effectiveUseOnline) {
val wordCount = countWords(word)
if (isProUser || wordCount <= 1) {
if (wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = word
showAiDefinitionPopup = true
scope.launch {
val token = viewModel.getAuthToken()
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = word, onUpdate = { chunk ->
text = word,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult =
AiDefinitionResult(definition = currentDefinition + chunk)
}, onError = { error ->
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
authToken = token,
onError = { error ->
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiDefinitionPopup = false
isAiDefinitionLoading = false
} else {
aiDefinitionResult = AiDefinitionResult(error = error)
}, onFinish = { isAiDefinitionLoading = false }, context = context
}
},
onFinish = { isAiDefinitionLoading = false },
context = context
)
}
} else {
showDictionaryUpsellDialog = true
}
} else {
if (!selectedDictPackage.isNullOrEmpty()) {
@ -728,10 +754,6 @@ fun EpubReaderHost(
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<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 chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
@ -796,10 +818,15 @@ fun EpubReaderHost(
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 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 drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@ -964,9 +991,14 @@ fun EpubReaderHost(
LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message ->
if (message == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
ttsController.stop()
} else {
bannerMessage = BannerMessage(message, isError = true)
}
}
}
LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) {
@ -983,7 +1015,9 @@ fun EpubReaderHost(
}
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) }
val speakerPlayer = remember(context, scope) {
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
}
var isAutoScrollModeActive by remember { mutableStateOf(false) }
var isAutoScrollPlaying by remember { mutableStateOf(false) }
@ -1041,7 +1075,6 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
@ -1102,6 +1135,11 @@ fun EpubReaderHost(
}
fun startTts() {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
if (isAutoScrollModeActive) {
isAutoScrollModeActive = false
isAutoScrollPlaying = false
@ -1114,6 +1152,7 @@ fun EpubReaderHost(
webView = webViewRefForTts,
onPaginatedStart = {
scope.launch {
val token = viewModel.getAuthToken()
val currentPage = paginatedPagerState.currentPage
val bookPaginator = paginator as? BookPaginator
val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage)
@ -1136,7 +1175,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
playbackSource = "READER",
authToken = token
)
}
}
@ -1153,8 +1193,14 @@ fun EpubReaderHost(
)
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
return
}
val action = {
scope.launch {
val token = viewModel.getAuthToken()
val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
@ -1191,7 +1237,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
playbackSource = "READER",
authToken = token
)
}
}
@ -1237,7 +1284,8 @@ fun EpubReaderHost(
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts,
scope = scope,
currentTtsMode = currentTtsMode
currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() }
)
TtsHighlightHandler(
@ -1338,12 +1386,15 @@ fun EpubReaderHost(
}
val runRecap = { chapterIdx: Int, charLimit: Int ->
showRecapPopup = true
showAiHubSheet = true
isRecapLoading = true
recapResult = null
recapProgressMessage = "Checking past chapters..."
scope.launch {
val token = viewModel.getAuthToken()
var currentCost: Double? = null
executeRecapLogic(
epubBook = epubBook,
chapterIndex = chapterIdx,
@ -1352,13 +1403,27 @@ fun EpubReaderHost(
paginator = paginator,
context = context,
onProgressUpdate = { recapProgressMessage = it },
onCostReceived = { cost ->
currentCost = cost
recapResult = recapResult?.copy(cost = cost) ?: SummarizationResult(cost = cost)
},
onResultUpdate = { chunk ->
isRecapLoading = false
val current = recapResult?.summary ?: ""
recapResult = SummarizationResult(summary = current + chunk)
recapResult = SummarizationResult(
summary = current + chunk,
cost = currentCost
)
},
authToken = token,
onError = { error ->
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showRecapPopup = false
isRecapLoading = false
} else {
recapResult = SummarizationResult(error = error)
}
},
onFinish = { isRecapLoading = false }
)
@ -2053,6 +2118,161 @@ fun EpubReaderHost(
}
}
val handleGenerateSummary: (Boolean) -> Unit = { force ->
if (!isProUser && credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
showAiHubSheet = true
isSummarizationLoading = true
summarizationResult = null
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
val cached = if (!force) summaryCacheManager.getSummary(
epubBook.title,
currentChapterIndex
) else null
if (cached != null) {
summarizationResult =
SummarizationResult(summary = cached, isCacheHit = true)
isSummarizationLoading = false
} else {
webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result ->
Timber.d("JS summarization request: $result")
} ?: run {
isSummarizationLoading = false
summarizationResult =
SummarizationResult(error = "WebView not available.")
}
}
}
RenderMode.PAGINATED -> {
scope.launch {
val currentPage = paginatedPagerState.currentPage
val token = viewModel.getAuthToken()
val chapterIndex =
(paginator as? BookPaginator)?.findChapterIndexForPage(currentPage)
Timber.tag("POS_DIAG")
.d("handleGenerateSummary (Paginated): currentPage=$currentPage -> resolved chapterIndex=$chapterIndex")
if (chapterIndex != null) {
val cached = if (!force) summaryCacheManager.getSummary(
epubBook.title,
chapterIndex
) else null
if (cached != null) {
summarizationResult =
SummarizationResult(summary = cached, isCacheHit = true)
isSummarizationLoading = false
return@launch
}
val text = paginator?.getPlainTextForChapter(chapterIndex)
if (!text.isNullOrBlank()) {
var currentCost: Double? = null
var currentFreeRemaining: Int? = null
val finalSummaryBuilder = StringBuilder()
summarizeBookContent(
content = text,
authToken = token,
onUsageReceived = { cost, freeRemaining ->
currentCost = cost
currentFreeRemaining = freeRemaining
summarizationResult = summarizationResult?.copy(
cost = cost, freeRemaining = freeRemaining
) ?: SummarizationResult(
cost = cost,
freeRemaining = freeRemaining
)
},
onUpdate = { chunk ->
finalSummaryBuilder.append(chunk)
val currentSummary = summarizationResult?.summary ?: ""
summarizationResult = SummarizationResult(
summary = currentSummary + chunk,
cost = currentCost,
freeRemaining = currentFreeRemaining
)
},
onError = { error ->
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiHubSheet = false
isSummarizationLoading = false
} else {
summarizationResult =
SummarizationResult(error = error)
}
},
onFinish = {
isSummarizationLoading = false
val fullSummary = finalSummaryBuilder.toString()
if (fullSummary.isNotBlank()) {
val chapterTitle =
chapters.getOrNull(chapterIndex)?.title
?: "Chapter ${chapterIndex + 1}"
summaryCacheManager.saveSummary(
epubBook.title,
chapterIndex,
chapterTitle,
fullSummary
)
}
})
} else {
summarizationResult =
SummarizationResult(error = "Could not get chapter content.")
isSummarizationLoading = false
}
} else {
summarizationResult =
SummarizationResult(error = "Could not determine current chapter.")
isSummarizationLoading = false
}
}
}
}
}
}
val handleGenerateRecap: () -> Unit = {
if (credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
showAiHubSheet = true
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
isRequestingRecapCfi = true
webViewRefForTts?.evaluateJavascript(
"javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());",
null
)
}
RenderMode.PAGINATED -> {
val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode
if (bookPaginator != null && chapterIndex != null) {
val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
val currentPageInChapter = paginatedPagerState.currentPage - startPage
val charsScrolled = bookPaginator.getCharactersScrolledInChapter(
chapterIndex,
currentPageInChapter
)
runRecap(chapterIndex, charsScrolled.toInt())
} else {
bannerMessage =
BannerMessage("Wait for book to load fully.", isError = true)
}
}
}
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets.statusBars,
@ -2552,6 +2772,7 @@ fun EpubReaderHost(
ttsScope = scope,
onTtsTextReady = { jsonString ->
scope.launch {
val token = viewModel.getAuthToken()
Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this
val ttsChunks = mutableListOf<TtsChunk>()
try {
@ -2587,9 +2808,14 @@ fun EpubReaderHost(
Timber.d("Vertical: Final compiled TTS chunks size: ${ttsChunks.size}")
if (ttsChunks.isNotEmpty()) {
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
showInsufficientCreditsDialog = true
ttsShouldStartOnChapterLoad = false
val chapterTitle =
chapters.getOrNull(currentChapterIndex)?.title
return@launch
}
ttsShouldStartOnChapterLoad = false
val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title
val coverUriString = coverImagePath?.let {
Uri.fromFile(File(it)).toString()
}
@ -2600,7 +2826,8 @@ fun EpubReaderHost(
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = currentTtsMode,
playbackSource = "READER"
playbackSource = "READER",
authToken = token
)
} else {
Timber.w("No TTS chunks were created from JSON, not starting TTS."
@ -2625,25 +2852,48 @@ fun EpubReaderHost(
onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization")
scope.launch {
val token = viewModel.getAuthToken()
val chapterIndexToSave = currentChapterIndex
val bookTitleToSave = epubBook.title
val finalSummaryBuilder = StringBuilder()
var currentCost: Double? = null
var currentFreeRemaining: Int? = null
summarizeBookContent(
content = content,
authToken = token,
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
currentCost = cost
currentFreeRemaining = freeRemaining
summarizationResult = summarizationResult?.copy(
cost = cost, freeRemaining = freeRemaining
) ?: SummarizationResult(cost = cost, freeRemaining = freeRemaining)
},
onUpdate = { chunk ->
finalSummaryBuilder.append(chunk)
val currentSummary = summarizationResult?.summary ?: ""
summarizationResult = SummarizationResult(summary = currentSummary + chunk)
summarizationResult = SummarizationResult(
summary = currentSummary + chunk,
cost = currentCost,
freeRemaining = currentFreeRemaining
)
},
onError = { error ->
summarizationResult = SummarizationResult(error = error)
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiHubSheet = false
isRecapLoading = false
} else {
recapResult = SummarizationResult(error = error)
}
},
onFinish = {
isSummarizationLoading = false
val fullSummary = finalSummaryBuilder.toString()
if (fullSummary.isNotBlank()) {
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, fullSummary)
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}"
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
}
}
)
@ -3594,7 +3844,6 @@ fun EpubReaderHost(
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
onToggleReflow = if (onToggleReflow != null) {
@ -3620,6 +3869,40 @@ fun EpubReaderHost(
label = "AutoScrollAlignAnimation"
)
val ttsOverlayPadding by animateDpAsState(
targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp,
label = "TtsOverlayPadding"
)
val ttsAlignmentBias by animateFloatAsState(
targetValue = if (isTtsCollapsed) 1f else 0f,
label = "TtsAlignAnimation"
)
AnimatedVisibility(
visible = isTtsSessionActive && showBars,
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
modifier = Modifier
.align(BiasAlignment(ttsAlignmentBias, 1f))
.padding(bottom = ttsOverlayPadding)
.padding(horizontal = 16.dp)
) {
TtsOverlayControls(
ttsController = ttsController,
ttsState = ttsState,
currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it },
onOpenTtsSettings = { showTtsSettingsSheet = true },
onClose = {
userStoppedTts = true
ttsController.stop()
},
credits = credits
)
}
val isAutoScrollControlsVisible = isAutoScrollModeActive
AnimatedVisibility(
@ -3725,7 +4008,7 @@ fun EpubReaderHost(
isProUser = isProUser,
hiddenTools = hiddenTools,
currentTtsMode = currentTtsMode,
onOpenTtsControls = { showTtsControlsSheet = true },
onOpenAiHub = { showAiHubSheet = true },
onOpenSlider = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
@ -3768,90 +4051,6 @@ fun EpubReaderHost(
showBars = true
showFormatAdjustmentBars = false
},
onSummarize = {
if (isProUser) {
showSummarizationPopup = true
isSummarizationLoading = true
summarizationResult = null
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result ->
Timber.d("JS summarization request: $result")
} ?: run {
isSummarizationLoading = false
summarizationResult = SummarizationResult(error = "WebView not available.")
}
}
RenderMode.PAGINATED -> {
scope.launch {
val currentPage = paginatedPagerState.currentPage
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage)
if (chapterIndex != null) {
val text = paginator?.getPlainTextForChapter(chapterIndex)
if (!text.isNullOrBlank()) {
summarizeBookContent(
content = text,
onUpdate = { chunk ->
val currentSummary =
summarizationResult?.summary
?: ""
summarizationResult =
SummarizationResult(
summary = currentSummary + chunk
)
},
onError = { error ->
summarizationResult =
SummarizationResult(
error = error
)
},
onFinish = {
isSummarizationLoading =
false
}
)
} else {
summarizationResult = SummarizationResult(error = "Could not get chapter content.")
isSummarizationLoading = false
}
} else {
summarizationResult = SummarizationResult(error = "Could not determine current chapter.")
isSummarizationLoading = false
}
}
}
}
} else {
showSummarizationUpsellDialog = true
}
},
onRecap = {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
isRequestingRecapCfi = true
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
}
RenderMode.PAGINATED -> {
val bookPaginator = paginator as? BookPaginator
val chapterIndex = currentChapterInPaginatedMode
if (bookPaginator != null && chapterIndex != null) {
val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
val currentPageInChapter = paginatedPagerState.currentPage - startPage
val charsScrolled = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
Timber.d("Paginated Mode: Chapter $chapterIndex, PageInChapter $currentPageInChapter")
Timber.d("Paginated Mode: Chars Scrolled (Limit): $charsScrolled")
runRecap(chapterIndex, charsScrolled.toInt())
} else {
bannerMessage = BannerMessage("Wait for book to load fully.", isError = true)
}
}
}
},
onToggleTts = {
if (isTtsSessionActive) {
Timber.d("TTS button clicked: Stopping TTS")
@ -3874,9 +4073,6 @@ fun EpubReaderHost(
}
}
},
onPlayPauseTts = {
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomPadding)
@ -3922,27 +4118,21 @@ fun EpubReaderHost(
.padding(horizontal = 16.dp)
)
val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) {
currentChapterInPaginatedMode ?: currentChapterIndex
} else {
currentChapterIndex
}
EpubReaderAiOverlays(
showSummarizationPopup = showSummarizationPopup,
bookTitle = epubBook.title,
summaryCacheManager = summaryCacheManager,
summarizationResult = summarizationResult,
isSummarizationLoading = isSummarizationLoading,
onDismissSummarization = {
showSummarizationPopup = false
isSummarizationLoading = false
summarizationResult = null
},
showSummarizationUpsellDialog = showSummarizationUpsellDialog,
onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false },
showRecapPopup = showRecapPopup,
recapResult = recapResult,
isRecapLoading = isRecapLoading,
onDismissRecap = {
showRecapPopup = false
isRecapLoading = false
recapResult = null
},
showAiDefinitionPopup = showAiDefinitionPopup,
selectedTextForAi = selectedTextForAi,
aiDefinitionResult = aiDefinitionResult,
@ -3962,12 +4152,31 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive,
onOpenExternalDictionary = { text ->
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
ExternalDictionaryHelper.launchDictionary(
context,
selectedDictPackage!!,
text
)
} else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
Toast.makeText(
context,
"Select an offline dictionary first.",
Toast.LENGTH_SHORT
).show()
showDictionarySettingsSheet = true
}
}
},
getAuthToken = { viewModel.getAuthToken() },
credits = credits,
isProUser = isProUser,
currentChapterIndex = effectiveCurrentChapterIndex,
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}",
showAiHubSheet = showAiHubSheet,
onGenerateSummary = handleGenerateSummary,
onGenerateRecap = handleGenerateRecap,
onDismissAiHub = { showAiHubSheet = false },
onClearSummary = { summarizationResult = null },
onClearRecap = { recapResult = null }
)
if (isNavigatingToPosition) {
@ -4087,8 +4296,8 @@ fun EpubReaderHost(
highlightToNoteCfi = null
},
onCopy = {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Copied Text", targetHighlight.text)
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", targetHighlight.text)
clipboardManager.setPrimaryClip(clip)
highlightToNoteCfi = null
},
@ -4176,15 +4385,9 @@ fun EpubReaderHost(
onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker)
},
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER"
)
}
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER",
getAuthToken = { viewModel.getAuthToken() },
bookTitle = epubBook.title
)
}
@ -4227,13 +4430,6 @@ fun EpubReaderHost(
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (showVisualOptionsSheet) {
VisualOptionsSheet(
systemUiMode = systemUiMode,
@ -4297,6 +4493,26 @@ fun EpubReaderHost(
)
}
if (showInsufficientCreditsDialog) {
AlertDialog(
onDismissRequest = { showInsufficientCreditsDialog = false },
icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) },
title = { Text("Out of Credits") },
text = { Text("You don't have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.") },
confirmButton = {
TextButton(onClick = {
showInsufficientCreditsDialog = false
onNavigateToPro()
}) { Text("Get Pro / Add Credits") }
},
dismissButton = {
TextButton(onClick = { showInsufficientCreditsDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showPaletteManager) {
PaletteManagerDialog(
currentPalette = currentHighlightPalette,

View file

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

View file

@ -257,16 +257,11 @@ class BookPaginator(
private fun getAllTextBlocks(blocks: List<ContentBlock>): List<TextContentBlock> {
return blocks.flatMap { block ->
when (block) {
is WrappingContentBlock -> {
Timber.d("PAGINATOR: Found WrappingContentBlock with ${block.paragraphsToWrap.size} paragraphs.")
getAllTextBlocks(block.paragraphsToWrap)
}
is WrappingContentBlock -> getAllTextBlocks(block.paragraphsToWrap)
is FlexContainerBlock -> getAllTextBlocks(block.children)
is TableBlock -> block.rows.flatten().flatMap { getAllTextBlocks(it.content) }
is TextContentBlock -> listOf(block)
else -> {
Timber.d("PAGINATOR: Skipping non-text block of type ${block::class.simpleName}")
emptyList()
}
else -> emptyList()
}
}
}
@ -833,7 +828,17 @@ class BookPaginator(
override fun getPlainTextForChapter(chapterIndex: Int): String? {
val chapter = chapters.getOrNull(chapterIndex) ?: return null
return Jsoup.parse(chapter.htmlContent).body().text()
Timber.tag("POS_DIAG").d("getPlainTextForChapter: chapterIndex=$chapterIndex, chapterTitle='${chapter.title}', hasInMemoryContent=${chapter.htmlContent.isNotEmpty()}")
val htmlToParse = chapter.htmlContent.ifEmpty {
try {
val file = java.io.File(extractionBasePath, chapter.htmlFilePath)
if (file.exists()) file.readText() else ""
} catch (_: Exception) {
""
}
}
if (htmlToParse.isBlank()) return null
return Jsoup.parse(htmlToParse).body().text()
}
private fun calculateAccurateStartIndex(targetChapterIndex: Int): Int {
@ -1075,11 +1080,13 @@ class BookPaginator(
suspend fun findPageForLocator(locator: Locator): Int? {
val targetChapterIndex = locator.chapterIndex
Timber.i("Finding page for locator: Chapter $targetChapterIndex, Block ${locator.blockIndex}, Offset ${locator.charOffset}")
Timber.tag("POS_DIAG").d("findPageForLocator: Searching for $locator")
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
val chapterStartPage = chapterStartPageIndices[targetChapterIndex] ?: 0
Timber.tag("POS_DIAG").d("findPageForLocator: targetChapterIndex=$targetChapterIndex, chapterStartPage=$chapterStartPage, chapterPages.size=${chapterPages?.size}")
if (chapterPages.isNullOrEmpty()) {
Timber.e("Locator navigation failed: Could not paginate target chapter $targetChapterIndex.")
return null
@ -1088,57 +1095,87 @@ class BookPaginator(
var fallbackPageInChapter = -1
for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
if (block.blockIndex == locator.blockIndex) {
Timber.tag("ThemeReconfig").d("Block Index Match: Found block ${locator.blockIndex} on page $pageIndex of Chapter $targetChapterIndex")
val allTextBlocks = getAllTextBlocks(page.content)
if (allTextBlocks.any { it.blockIndex == locator.blockIndex }) {
Timber.tag("POS_DIAG").d("findPageForLocator: Found target blockIndex ${locator.blockIndex} on PageInChapter $pageIndex (Abs ${chapterStartPage + pageIndex})")
}
for (textBlock in allTextBlocks) {
if (textBlock.blockIndex == locator.blockIndex) {
if (fallbackPageInChapter == -1) {
fallbackPageInChapter = pageIndex
}
val textBlock = block as? TextContentBlock
if (textBlock != null) {
val startOffsetOnPage = textBlock.startCharOffsetInSource
val endOffsetOnPage = startOffsetOnPage + textBlock.content.length
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
Timber.tag("ThemeReconfig").d("Offset Check: Target ${locator.charOffset} vs Range [$startOffsetOnPage, $endOffsetOnPage]. Inside: $isInside")
Timber.tag("POS_DIAG").d(" -> Block Match: page=$pageIndex, targetOffset=${locator.charOffset}, blockRange=[$startOffsetOnPage, $endOffsetOnPage]")
val isInside = locator.charOffset in startOffsetOnPage..<endOffsetOnPage
if (isInside) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND match on absolute page $finalPageIndex")
return finalPageIndex
}
} else {
return chapterStartPage + pageIndex
if (textBlock.content.isEmpty() && locator.charOffset == startOffsetOnPage) {
val finalPageIndex = chapterStartPage + pageIndex
Timber.tag("POS_DIAG").i("findPageForLocator: FOUND empty block match on absolute page $finalPageIndex")
return finalPageIndex
}
}
}
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) {
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
}
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
}
fun getLocatorForPage(pageIndex: Int): Locator? {
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 firstTextBlock = pageContent.content.firstOrNull { it is TextContentBlock } as? TextContentBlock
val targetBlock = firstTextBlock ?: pageContent.content.firstOrNull() ?: return null
val charOffset = (targetBlock as? TextContentBlock)?.startCharOffsetInSource ?: 0
Timber.tag("POS_DIAG").d("getLocatorForPage: Inspecting page $pageIndex (chapter=$chapterIndex). Total top-level blocks=${pageContent.content.size}")
return Locator(
val allTextBlocks = getAllTextBlocks(pageContent.content)
val firstTextBlock = allTextBlocks.firstOrNull { it.content.text.isNotBlank() } ?: allTextBlocks.firstOrNull()
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 = targetBlock.blockIndex,
charOffset = charOffset
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) {

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
@ -250,7 +252,17 @@ class ContentStyler(
)
}
return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)
val newTextDecorationColor = if (style.textDecorationColor.isSpecified) {
CssParser.adaptColorForTheme(style.textDecorationColor, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
} else {
style.textDecorationColor
}
return style.copy(
spanStyle = newSpanStyle,
blockStyle = newBlockStyle,
textDecorationColor = newTextDecorationColor
)
}
private fun embedImagesInSvg(svgContent: String): String {
@ -402,12 +414,44 @@ class ContentStyler(
else -> null
}
val finalSpanStyle = themedSpanStyle.spanStyle.copy(
var finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = effectiveSpanFontFamily,
baselineShift = baselineShift
)
val hasCustomDeco = themedSpanStyle.textDecorationStyle != null ||
themedSpanStyle.textDecorationColor.isSpecified ||
themedSpanStyle.textUnderlineOffset.isSpecified
val combinedDeco = finalSpanStyle.textDecoration ?: TextDecoration.None
if (hasCustomDeco && combinedDeco.contains(TextDecoration.Underline)) {
val decos = mutableListOf<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)
val ws = themedSpanStyle.wordSpacing
if (ws.isSpecified && ws.value != 0f) {
val textToStyle = block.text.substring(span.start, span.end)
for (i in textToStyle.indices) {
if (textToStyle[i] == ' ') {
addStyle(SpanStyle(letterSpacing = ws), span.start + i, span.start + i + 1)
}
}
}
if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end)
}

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.isSpecified
import timber.log.Timber
import java.io.File
import java.util.regex.Pattern
@ -427,6 +427,11 @@ object CssParser {
var marginBottomStr: String? = null
var marginLeftStr: String? = null
var wordSpacing: TextUnit = TextUnit.Unspecified
var textDecorationStyle: String? = null
var textDecorationColor: Color = Color.Unspecified
var textUnderlineOffset: Dp = Dp.Unspecified
var borderTopWidth: Dp? = null
var borderRightWidth: Dp? = null
var borderBottomWidth: Dp? = null
@ -566,14 +571,42 @@ object CssParser {
}
}
"text-decoration" -> {
spanStyle = spanStyle.copy(
textDecoration = when(value) {
"underline" -> TextDecoration.Underline
"line-through" -> TextDecoration.LineThrough
"none" -> TextDecoration.None
else -> spanStyle.textDecoration
val parts = value.split(" ")
val decos = mutableListOf<TextDecoration>()
if (parts.contains("underline")) decos.add(TextDecoration.Underline)
if (parts.contains("line-through")) decos.add(TextDecoration.LineThrough)
if (parts.contains("none")) {
spanStyle = spanStyle.copy(textDecoration = TextDecoration.None)
} else if (decos.isNotEmpty()) {
spanStyle = spanStyle.copy(textDecoration = TextDecoration.combine(decos))
}
)
val styles = listOf("solid", "double", "dotted", "dashed", "wavy")
parts.firstOrNull { it in styles }?.let { textDecorationStyle = it }
parts.firstNotNullOfOrNull { parseColor(it) }?.let { color ->
textDecorationColor = this@CssParser.adaptColorForTheme(color, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
}
}
"word-spacing" -> {
val trimmedValue = value.trim()
wordSpacing = if (trimmedValue.lowercase() == "normal") {
TextUnit.Unspecified
} else {
parseCssDimensionToTextUnit(value, containerWidthPx, density)
}
}
"text-decoration-style" -> {
textDecorationStyle = value
}
"text-decoration-color" -> {
parseColor(value)?.let {
textDecorationColor = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false, themeBackgroundColor, themeTextColor)
}
}
"text-underline-offset" -> {
textUnderlineOffset = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
}
"letter-spacing" -> {
val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density)
@ -623,9 +656,9 @@ object CssParser {
"padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"width" -> width = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssDimension(value, baseFontSizeSp, density, containerWidthPx)
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
@ -872,7 +905,10 @@ object CssParser {
borderCollapse = borderCollapse,
borderSpacing = borderSpacing
)
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis)
return CssStyle(
spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis,
wordSpacing, textDecorationStyle, textDecorationColor, textUnderlineOffset
)
}
private fun parseShorthand4(value: String, baseFontSize: Float, density: Float, containerWidth: Int): List<Dp> {
@ -939,7 +975,46 @@ object CssParser {
return Triple(w, s, c)
}
// ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssDimension(
size: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
if (trimmed in listOf("auto", "none", "max-content", "min-content", "fit-content", "inherit", "initial")) {
return Dp.Unspecified
}
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: Dp.Unspecified
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: Dp.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: Dp.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: Dp.Unspecified
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
Dp.Unspecified
}
}
trimmed.endsWith("vw") -> {
val percent = trimmed.removeSuffix("vw").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
Dp.Unspecified
}
}
trimmed.endsWith("vh") -> Dp.Unspecified
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> Dp.Unspecified
}
}
internal fun parseCssSizeToDp(
size: String,
baseFontSizeSp: Float,
@ -947,45 +1022,9 @@ object CssParser {
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
// Handle keywords
BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it }
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: 0.dp
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: 0.dp
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: 0.dp // 1pt ≈ 1.33px
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
0.dp
}
}
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> 0.dp
}
}
internal fun parseCssDimensionToTextUnit(
dimension: String?,
containerWidthPx: Int,
density: Float
): TextUnit {
if (dimension.isNullOrBlank()) return TextUnit.Unspecified
val trimmed = dimension.trim().lowercase()
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.sp ?: TextUnit.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("%") -> trimmed.removeSuffix("%").toFloatOrNull()?.let { (it / 100f).em } ?: TextUnit.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).sp } ?: TextUnit.Unspecified
else -> TextUnit.Unspecified
}
val dim = parseCssDimension(size, baseFontSizeSp, density, containerWidthPx)
return if (dim.isSpecified) dim else 0.dp
}
internal fun parseColor(colorString: String): Color? {

View file

@ -23,15 +23,18 @@ import android.graphics.BitmapFactory
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
@ -131,7 +134,7 @@ private class SemanticHtmlParser(
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // Semantic parsing is always theme-agnostic
isDarkTheme = false
)
if (inlineParseResult.fontFaces.isNotEmpty()) {
@ -144,9 +147,7 @@ private class SemanticHtmlParser(
}
val body = document.body()
return body.children().flatMap { childElement ->
parseNodeToSemanticBlocks(childElement, getElementStyle(body))
}
return parseContainer(body, getElementStyle(body))
}
private fun parseNodeToSemanticBlocks(
@ -267,9 +268,15 @@ private class SemanticHtmlParser(
elementStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
val childStyle = elementStyle.copy(
blockStyle = elementStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
@ -280,16 +287,57 @@ private class SemanticHtmlParser(
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val hasNonTextChildren = element.select("img, svg, math-placeholder, table, hr, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (hasNonTextChildren) {
val level = tagName.substring(1).toIntOrNull() ?: 1
val fontSizeMultiplier = when (level) {
1 -> 1.5f; 2 -> 1.4f; 3 -> 1.3f; 4 -> 1.2f; 5 -> 1.1f; else -> 1.0f
}
val headerStyle = elementStyle.copy(
spanStyle = elementStyle.spanStyle.copy(
fontWeight = FontWeight.Bold,
fontSize = (textStyle.fontSize.value * fontSizeMultiplier).sp
)
)
val hasBoxStyles = headerStyle.blockStyle.backgroundColor.isSpecified ||
headerStyle.blockStyle.borderTop != null ||
headerStyle.blockStyle.borderRight != null ||
headerStyle.blockStyle.borderBottom != null ||
headerStyle.blockStyle.borderLeft != null ||
headerStyle.blockStyle.padding != BoxBorders() ||
headerStyle.blockStyle.borderTopLeftRadius > 0.dp ||
headerStyle.blockStyle.borderTopRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomRightRadius > 0.dp ||
headerStyle.blockStyle.borderBottomLeftRadius > 0.dp
if (hasBoxStyles) {
val childStyle = headerStyle.copy(
blockStyle = headerStyle.blockStyle.copy(
backgroundColor = Color.Unspecified,
borderTop = null, borderRight = null, borderBottom = null, borderLeft = null,
padding = BoxBorders(),
margin = BoxBorders()
)
)
val children = parseContainer(element, childStyle)
listOf(SemanticFlexContainer(children, headerStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, headerStyle)
}
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
if (element.isBlock) {
val hasBlockDescendant = !element.isBlock && element.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty()
if (element.isBlock || hasBlockDescendant) {
parseContainer(element, elementStyle)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
@ -316,13 +364,30 @@ private class SemanticHtmlParser(
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
children.add(SemanticParagraph(text, spans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) }
val finalSpans = spans.toMutableList()
if (element.tagName().lowercase() == "a") {
val href = element.attr("href").ifBlank { null }
if (href != null) {
finalSpans.add(SemanticSpan(
start = 0,
end = text.length,
style = style,
linkHref = href,
tag = "a",
elementId = element.id().ifBlank { null }
))
}
}
children.add(SemanticParagraph(text, finalSpans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++))
}
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val isEffectivelyBlock = node.isBlock || node.tagName().lowercase() in listOf("img", "svg", "math-placeholder", "hr")
val tagName = node.tagName().lowercase()
val isEffectivelyBlock = node.isBlock || tagName in listOf("img", "svg", "math-placeholder", "hr") ||
(!node.isBlock && node.select("img, svg, math-placeholder, hr, table, div, p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, figure, article, aside, header, footer, nav, section, main").isNotEmpty())
if (isEffectivelyBlock) {
flushTextBuffer()
@ -462,8 +527,12 @@ private class SemanticHtmlParser(
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
.let {
Timber.tag("IMAGE_DIAG").d("Parsed file bounds: ${it.outWidth}x${it.outHeight} for ${imageFile.name}")
Pair(it.outWidth.toFloat(), it.outHeight.toFloat())
}
} catch (e: Exception) {
Timber.tag("IMAGE_DIAG").e(e, "Failed to parse image bounds for ${imageFile.name}")
Pair(null, null)
}
}

View file

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

View file

@ -1,4 +1,6 @@
// PaginatedReader.kt
@file:Suppress("VariableNeverRead")
package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
@ -9,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.Toast
import androidx.compose.ui.unit.isSpecified
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -24,7 +27,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -49,6 +51,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@ -73,6 +76,8 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
@ -614,6 +619,18 @@ fun PaginatedReaderScreen(
)
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
Timber.tag("PageTurnDiag").i("Pager Settled: Now on page $page at ${System.currentTimeMillis()}")
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.isScrollInProgress }.collect { isScrolling ->
Timber.tag("PageTurnDiag").d("Pager Scroll State: isScrolling=$isScrolling")
}
}
LaunchedEffect(fontSizeMultiplier, lineHeightMultiplier, paragraphGapMultiplier, fontFamily, textAlign) {
if (fontSizeMultiplier != debouncedFontSizeMult || lineHeightMultiplier != debouncedLineHeightMult || paragraphGapMultiplier != debouncedParagraphGapMult || fontFamily != debouncedFontFamily || textAlign != debouncedTextAlign) {
Timber.d("Formatting changed. Waiting for debounce.")
@ -755,7 +772,7 @@ fun PaginatedReaderScreen(
LaunchedEffect(paginator) {
if (anchorLocatorForReconfig != null) {
Timber.tag("ThemeReconfig").d("Restoration Effect Triggered for Locator: $anchorLocatorForReconfig")
Timber.tag("POS_DIAG").d("Restoration Triggered. Anchor Locator: $anchorLocatorForReconfig")
snapshotFlow { paginator.isLoading }.filter { !it }.first()
@ -763,19 +780,15 @@ fun PaginatedReaderScreen(
if (targetLocator != null) {
val page = paginator.findPageForLocator(targetLocator)
Timber.tag("ThemeReconfig").d("""
Restoration Progress:
- Target Locator: $targetLocator
- Paginator found Page: $page
- Chapter Start Page: ${paginator.chapterStartPageIndices[targetLocator.chapterIndex]}
""".trimIndent())
Timber.tag("POS_DIAG").d("Restoration Result: Paginator resolved locator to page: $page")
if (page != null) {
pagerState.scrollToPage(page)
Timber.tag("POS_DIAG").i("Restoration: Pager scrolled to $page")
} else {
val startPage = paginator.chapterStartPageIndices[targetLocator.chapterIndex]
if (startPage != null) {
Timber.tag("ThemeReconfig").w("Precise page not found, falling back to chapter start: $startPage")
Timber.tag("POS_DIAG").w("Restoration: Precise page not found, falling back to chapter start: $startPage")
pagerState.scrollToPage(startPage)
}
}
@ -831,7 +844,15 @@ fun PaginatedReaderScreen(
textStyle = textStyle,
horizontalPadding = horizontalPadding,
verticalPadding = verticalPadding,
onGetPage = { pageIndex -> paginator.getPageContent(pageIndex) },
onGetPage = { pageIndex ->
val startTime = System.currentTimeMillis()
val result = paginator.getPageContent(pageIndex)
val duration = System.currentTimeMillis() - startTime
if (duration > 16) {
Timber.tag("PageTurnDiag").w("HEAVY TASK: paginator.getPageContent($pageIndex) took ${duration}ms on Thread ${Thread.currentThread().name}")
}
result
},
onGetChapterPath = { pageIndex -> paginator.getChapterPathForPage(pageIndex) },
onGetChapterInfo = { pageIndex ->
paginator.findChapterIndexForPage(pageIndex)?.let { chapterIndex ->
@ -1186,8 +1207,206 @@ private fun TextWithEmphasis(
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val scope = rememberCoroutineScope()
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 drawStartTime = System.currentTimeMillis()
textLayoutResult?.let { layoutResult ->
if (activeSelection != null) {
// ADD absolute offset helper:
@ -1219,49 +1438,61 @@ private fun TextWithEmphasis(
val path = layoutResult.getPathForRange(sOffset, eOffset)
drawPath(path, Color(0xFF1976D2).copy(alpha = 0.3f))
} catch (e: Exception) {
Timber.e(e, "Highlight path out of bounds")
Timber.tag("DecorationsDiag").e(e, "Highlight path out of bounds")
}
}
}
}
if (block.cfi != null && userHighlights.isNotEmpty()) {
userHighlights.forEach { highlight ->
val range = getHighlightOffsetsInBlock(block, highlight)
if (range != null) {
try {
val path = layoutResult.getPathForRange(range.first, range.last + 1)
drawPath(path, highlight.color.color.copy(alpha = 0.4f), blendMode = BlendMode.SrcOver)
if (highlight.cfi == pressedHighlightCfi) {
drawPath(path, Color.Black.copy(alpha = 0.1f), blendMode = BlendMode.SrcOver)
}
} catch (_: Exception) { }
}
}
cachedHighlights.forEach { (path, color) ->
drawPath(path, color, blendMode = BlendMode.SrcOver)
}
val emphasisAnnotations = text.getStringAnnotations("TextEmphasis", 0, text.length)
if (emphasisAnnotations.isNotEmpty()) {
emphasisAnnotations.forEach { annotation ->
val emphasis = parseEmphasisAnnotation(annotation.item, style.color)
val markColor = if (emphasis.color.isSpecified) emphasis.color else style.color
val markSize = layoutResult.layoutInput.style.fontSize.toPx() * 0.3f
for (offset in annotation.start until annotation.end) {
if (offset >= text.text.length || text.text[offset].isWhitespace()) continue
try {
val boundingBox = layoutResult.getBoundingBox(offset)
val center = Offset(
boundingBox.center.x,
if (emphasis.position == "under") boundingBox.bottom + markSize * 0.1f
else boundingBox.top - markSize * 0.1f
cachedEmphasisMarks.forEach { mark ->
drawCircle(mark.color, mark.radius, mark.center, style = Stroke(1f))
}
cachedUnderlines.forEach { line ->
when (line.decoStyle) {
"wavy" -> {
line.path?.let { p ->
drawPath(p, color = line.decoColor, style = Stroke(width = 1.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round))
}
}
"dashed", "dotted" -> {
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y),
end = Offset(line.maxX, line.y),
strokeWidth = if (line.decoStyle == "dotted") 2.dp.toPx() else 1.dp.toPx(),
cap = if (line.decoStyle == "dotted") StrokeCap.Round else StrokeCap.Butt,
pathEffect = line.effect
)
}
else -> { // Solid or Double
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y),
end = Offset(line.maxX, line.y),
strokeWidth = 1.dp.toPx()
)
if (line.decoStyle == "double") {
drawLine(
color = line.decoColor,
start = Offset(line.minX, line.y + 2.dp.toPx()),
end = Offset(line.maxX, line.y + 2.dp.toPx()),
strokeWidth = 1.dp.toPx()
)
drawCircle(markColor, markSize / 2, center, style = Stroke(1f))
} catch (_: Exception) { }
}
}
}
}
}
val drawDuration = System.currentTimeMillis() - drawStartTime
if (drawDuration > 5) {
Timber.tag("DecorationsDiag").w("Modifier.drawBehind took ${drawDuration}ms for block ${block.blockIndex}")
}
}
fun getHighlightAt(offset: Offset, layout: TextLayoutResult): Pair<UserHighlight, Rect>? {
if (block.cfi == null) return null
@ -1568,6 +1799,7 @@ internal fun PaginatedReaderContent(
val down = event.changes.firstOrNull { it.pressed }
if (down != null) {
pageTurnTouchY = down.position.y
Timber.tag("PageTurnFixDiag").v("Touch Event: Y=${down.position.y} at OffsetFraction=${pagerState.currentPageOffsetFraction}")
}
}
}
@ -1592,10 +1824,21 @@ internal fun PaginatedReaderContent(
var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, uiState.generation) {
val fetchStartTime = System.currentTimeMillis()
Timber.tag("PageTurnDiag").d("Page $pageIndex: Starting content fetch")
pageContent = onGetPage(pageIndex)
val fetchDuration = System.currentTimeMillis() - fetchStartTime
Timber.tag("PageTurnDiag").d("Page $pageIndex: Content fetched in ${fetchDuration}ms")
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
}
SideEffect {
Timber.tag("PageTurnDiag").v("Page $pageIndex: Re-composing content area")
}
val textBlocksOnPage =
pageContent?.content?.extractTextBlocks()
?.filter { it.cfi != null } ?: emptyList()
@ -2263,10 +2506,6 @@ internal fun PaginatedReaderContent(
}
is FlexContainerBlock -> {
// Background, border, and padding are
// already applied by the outer Box wrapper.
// Only apply padding + width here.
val containerModifier = paddingModifier
if (block.style.flexDirection == "row") {
val horizontalArrangement =
@ -2284,7 +2523,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Top
}
Row(
modifier = containerModifier.fillMaxWidth(),
modifier = paddingModifier.fillMaxWidth(),
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment
) {
@ -2336,7 +2575,7 @@ internal fun PaginatedReaderContent(
else -> Alignment.Start
}
Column(
modifier = containerModifier.fillMaxWidth(),
modifier = paddingModifier.fillMaxWidth(),
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment
) {
@ -2505,27 +2744,21 @@ internal fun PaginatedReaderContent(
is ImageBlock -> {
val style = block.style
val finalImageModifier = Modifier.then(
if (style.width != Dp.Unspecified) Modifier.width(
style.width
)
if (style.width.isSpecified && style.width > 0.dp) Modifier.width(style.width)
else Modifier.fillMaxWidth()
).then(
if (style.maxWidth.isSpecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth)
else Modifier
).then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(
max = style.maxWidth
)
else Modifier
).then(
if (block.intrinsicWidth != null && block.intrinsicHeight != null && block.intrinsicWidth > 0f && block.intrinsicHeight > 0f) {
Modifier.aspectRatio(
block.intrinsicWidth / block.intrinsicHeight,
matchHeightConstraintsFirst = false
)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
if (block.expectedHeight > 0) {
Modifier.height(with(density) { block.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
).then(paddingModifier)
.onGloballyPositioned { coords ->
Timber.tag("IMAGE_DIAG").v("Actual Rendered Height for [#${block.blockIndex}]: ${coords.size.height}px")
}
val colorFilter =
if (block.style.filter == "invert(100%)") {
@ -2732,22 +2965,11 @@ internal fun PaginatedReaderContent(
}
is ImageBlock -> {
val imageModifier =
Modifier.fillMaxWidth()
.then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(
blockInCell.intrinsicWidth / blockInCell.intrinsicHeight,
matchHeightConstraintsFirst = false
)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(
blockInCell.style.height
)
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
} else {
Modifier.height(
250.dp
)
Modifier.height(250.dp)
}
)
AsyncImage(
@ -3457,18 +3679,16 @@ private fun RenderFlexChildBlock(
val style = childBlock.style
val imageModifier = Modifier
.then(
if (style.width != Dp.Unspecified) Modifier.width(style.width)
if (style.width != Dp.Unspecified && style.width > 0.dp) Modifier.width(style.width)
else Modifier
)
.then(
if (style.maxWidth != Dp.Unspecified) Modifier.widthIn(max = style.maxWidth)
if (style.maxWidth != Dp.Unspecified && style.maxWidth > 0.dp) Modifier.widthIn(max = style.maxWidth)
else Modifier
)
.then(
if (childBlock.intrinsicWidth != null && childBlock.intrinsicHeight != null && childBlock.intrinsicWidth > 0f && childBlock.intrinsicHeight > 0f) {
Modifier.aspectRatio(childBlock.intrinsicWidth / childBlock.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (style.height != Dp.Unspecified) {
Modifier.height(style.height)
if (childBlock.expectedHeight > 0) {
Modifier.height(with(density) { childBlock.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
@ -3582,10 +3802,8 @@ private fun RenderFlexChildBlock(
)
} else if (blockInCell is ImageBlock) {
val imageModifier = Modifier.fillMaxWidth().then(
if (blockInCell.intrinsicWidth != null && blockInCell.intrinsicHeight != null && blockInCell.intrinsicWidth > 0f && blockInCell.intrinsicHeight > 0f) {
Modifier.aspectRatio(blockInCell.intrinsicWidth / blockInCell.intrinsicHeight, matchHeightConstraintsFirst = false)
} else if (blockInCell.style.height != Dp.Unspecified) {
Modifier.height(blockInCell.style.height)
if (blockInCell.expectedHeight > 0) {
Modifier.height(with(density) { blockInCell.expectedHeight.toDp() })
} else {
Modifier.height(250.dp)
}
@ -3625,6 +3843,11 @@ private fun Modifier.realisticBookPage(
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
// Log composition frequency
SideEffect {
Timber.tag("PageTurnFixDiag").v("Page $pageIndex re-composed. Offset: ${pagerState.currentPageOffsetFraction}")
}
val frontPath = remember { Path() }
val backPath = remember { Path() }
val reflectedScreenPath = remember { Path() }
@ -3633,6 +3856,11 @@ private fun Modifier.realisticBookPage(
.graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
// Log layer property updates
if (abs(pageOffset) > 0.001f && abs(pageOffset) < 0.999f) {
Timber.tag("PageTurnFixDiag").d("graphicsLayer: Page $pageIndex, Offset: $pageOffset")
}
if (pageOffset <= 1f && pageOffset > -1f) {
translationX = -pageOffset * size.width
}
@ -3644,6 +3872,7 @@ private fun Modifier.realisticBookPage(
}
}
.drawWithContent {
val drawStart = System.nanoTime()
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
if (abs(pageOffset) < 0.001f) {
@ -3670,10 +3899,21 @@ private fun Modifier.realisticBookPage(
val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy)
// CRITICAL GEOMETRY LOG
if (progress > 0.8f) { // Focus logs on the "end" of the turn where the stall happens
Timber.tag("PageTurnFixDiag").i(
"Geometry Page $pageIndex: progress=$progress, nLen=$nLen, cornerY=$cornerY, dragX=$dragX, midX=$midX"
)
}
if (nLen > 0f) {
val nx = dx / nLen
val ny = dy / nLen
if (nx.isNaN() || ny.isNaN()) {
Timber.tag("PageTurnFixDiag").e("NAN DETECTED in Normal Vectors: nx=$nx, ny=$ny")
}
val huge = w * 3f
val vx = -ny
@ -3733,17 +3973,12 @@ private fun Modifier.realisticBookPage(
clipRect(0f, 0f, w, h) {
clipPath(frontPath) {
drawPath(reflectedScreenPath, color = paperColor)
val flapTint = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
drawPath(reflectedScreenPath, color = flapTint)
val innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.05f),
Color.Transparent
),
colors = listOf(Color.Black.copy(alpha = 0.25f), Color.Black.copy(alpha = 0.05f), Color.Transparent),
start = Offset(midX, midY),
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
)
@ -3769,16 +4004,15 @@ private fun Modifier.realisticBookPage(
drawContent()
}
}
else if (pageOffset > 0f && pageOffset <= 1f) {
drawRect(color = paperColor)
drawContent()
val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f)
drawRect(color = Color.Black.copy(alpha = dimAlpha))
}
else {
drawRect(color = paperColor)
drawContent()
}
val drawDuration = (System.nanoTime() - drawStart) / 1_000_000.0
if (drawDuration > 12.0) { // Log slow frames (anything near the 16ms frame budget)
Timber.tag("PageTurnFixDiag").w("Slow Draw on Page $pageIndex: ${drawDuration}ms")
}
}
}

View file

@ -303,7 +303,11 @@ data class CssStyle(
@ProtoNumber(9) val content: String? = null,
@ProtoNumber(10) val hyphens: String? = null,
@ProtoNumber(11) val fontVariantNumeric: String? = null,
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null,
@ProtoNumber(13) @Serializable(with = TextUnitSerializer::class) val wordSpacing: TextUnit = TextUnit.Unspecified,
@ProtoNumber(14) val textDecorationStyle: String? = null,
@ProtoNumber(15) @Serializable(with = ColorSerializer::class) val textDecorationColor: Color = Color.Unspecified,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val textUnderlineOffset: Dp = Dp.Unspecified
) {
fun merge(other: CssStyle): CssStyle {
return CssStyle(
@ -318,7 +322,11 @@ data class CssStyle(
content = other.content ?: this.content,
hyphens = other.hyphens ?: this.hyphens,
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric,
textEmphasis = other.textEmphasis ?: this.textEmphasis
textEmphasis = other.textEmphasis ?: this.textEmphasis,
wordSpacing = if (other.wordSpacing.isSpecified) other.wordSpacing else this.wordSpacing,
textDecorationStyle = other.textDecorationStyle ?: this.textDecorationStyle,
textDecorationColor = if (other.textDecorationColor.isSpecified) other.textDecorationColor else this.textDecorationColor,
textUnderlineOffset = if (other.textUnderlineOffset.isSpecified) other.textUnderlineOffset else this.textUnderlineOffset
)
}
}

View file

@ -742,26 +742,25 @@ private suspend fun measureBlockHeight(
val imageIntrinsicWidth = block.intrinsicWidth
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 styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
val measuredHeight = when {
styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
val styledWidthDp = block.style.width
val imageRenderWidthPx = if (styledWidthDp != Dp.Unspecified) {
with(density) { styledWidthDp.toPx() }
} else {
contentMaxWidth
styledWidthPx * aspectRatio
}
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
contentMaxWidth * aspectRatio
}
else -> with(density) { 250.dp.toPx() }
}
val height = (imageRenderWidthPx * aspectRatio).roundToInt()
height
} else {
Timber.w("Image at '${block.path}' has no valid intrinsic dimensions, falling back to fixed height.")
if (block.style.height != Dp.Unspecified) {
with(density) { block.style.height.toPx().roundToInt() }
} else {
with(density) { 250.dp.toPx().roundToInt() }
}
}
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
finalHeight
}
is SpacerBlock -> {
val height = with(density) { block.height.toPx().roundToInt() }

View file

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

View file

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

View file

@ -439,6 +439,7 @@ internal fun PdfPageComposable(
isVisible: Boolean = true,
isActivePage: Boolean = true,
isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
userHighlights: List<PdfUserHighlight> = emptyList(),
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
@ -1073,6 +1074,7 @@ internal fun PdfPageComposable(
canvasHeightPx.floatValue,
isVerticalScroll,
isScrolling,
isAutoScrollPlaying,
virtualPage,
isActivePage
) {
@ -1109,7 +1111,17 @@ internal fun PdfPageComposable(
try {
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect ->
snapshotFlow {
val rect = visibleScreenRect()
if (rect == null) null
else {
val qTop = rect.top / (tileSizePx / 2)
val qLeft = rect.left / (tileSizePx / 2)
val qBottom = rect.bottom / (tileSizePx / 2)
val qRight = rect.right / (tileSizePx / 2)
listOf(qTop, qLeft, qBottom, qRight)
}
}.conflate().collectLatest { _ ->
delay(150)
@ -1120,6 +1132,8 @@ internal fun PdfPageComposable(
return@collectLatest
}
val currentVisibleRect = visibleScreenRect()
val pxTl: Float
val pxBr: Float
val pyTl: Float

View file

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

View file

@ -32,7 +32,6 @@ import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.material3.Switch
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tune
import androidx.compose.foundation.rememberScrollState
import android.graphics.Bitmap
import android.graphics.RectF
@ -269,8 +268,8 @@ import androidx.paging.compose.itemKey
import androidx.work.WorkInfo
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.AiHubBottomSheet
import com.aryan.reader.BuildConfig
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.FileType
import com.aryan.reader.HighlightColorPickerDialog
import com.aryan.reader.MainViewModel
@ -279,15 +278,14 @@ import com.aryan.reader.ReaderTheme
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.TtsControlsSheet
import com.aryan.reader.epubreader.TtsOverlayControls
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
@ -306,7 +304,6 @@ import com.aryan.reader.saveCustomThemes
import com.aryan.reader.summarizationUrl
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import io.legere.pdfiumandroid.api.Bookmark
@ -1099,7 +1096,7 @@ private fun PdfTocTreeItem(
@OptIn(UnstableApi::class)
@Suppress("unused")
private fun saveTtsMode(context: Context, mode: TtsPlaybackManager.TtsMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@ -1229,7 +1226,9 @@ fun PdfViewerScreen(
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
val documentCache = remember { DocumentCache(3) }
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
val tabStateMap = remember { mutableStateMapOf<String, Int>() }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
val activeTheme = remember(currentThemeId, customThemes) {
PdfBuiltInThemes.find { it.id == currentThemeId }
@ -1298,6 +1297,7 @@ fun PdfViewerScreen(
}
var currentBookId by remember { mutableStateOf<String?>(null) }
val bookId = currentBookId ?: effectivePdfUri.toString().hashCode().toString()
var documentMetadataTitle by remember { mutableStateOf<String?>(null) }
val view = LocalView.current
var isDockDragging by remember { mutableStateOf(false) }
var initialScrollDone by remember { mutableStateOf(false) }
@ -1320,14 +1320,24 @@ fun PdfViewerScreen(
var isAutoScrollTempPaused by remember { mutableStateOf(false) }
val autoScrollResumeJob = remember { mutableStateOf<Job?>(null) }
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
var isMusicianMode by remember { mutableStateOf(loadPdfMusicianMode(context)) }
var autoScrollUseSlider by remember { mutableStateOf(loadPdfAutoScrollUseSlider(context)) }
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
ttsState.currentText
var currentTtsMode by remember {
mutableStateOf(
com.aryan.reader.tts.loadTtsMode(context).let {
if (BuildConfig.FLAVOR == "oss") TtsPlaybackManager.TtsMode.BASE else it
}
)
}
var showTtsSettingsSheet by remember { mutableStateOf(false) }
DisposableEffect(isKeepScreenOn) {
view.keepScreenOn = isKeepScreenOn
@ -1342,8 +1352,6 @@ fun PdfViewerScreen(
var selectedTranslatePackage by remember { mutableStateOf(loadExternalTranslatePackage(context)) }
var selectedSearchPackage by remember { mutableStateOf(loadExternalSearchPackage(context)) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
fun triggerAutoScrollTempPause(durationMs: Long) {
if (!isAutoScrollModeActive || !isAutoScrollPlaying) return
autoScrollResumeJob.value?.cancel()
@ -1587,6 +1595,19 @@ fun PdfViewerScreen(
}
}
LaunchedEffect(ttsState.errorMessage) {
ttsState.errorMessage?.let { message ->
if (message == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
ttsController.stop()
} else {
coroutineScope.launch {
snackbarHostState.showSnackbar(message)
}
}
}
}
val annotationSettingsRepo = remember(context) { AnnotationSettingsRepository(context) }
val toolSettings by annotationSettingsRepo.settings.collectAsState()
var showToolSettings by rememberSaveable { mutableStateOf(false) }
@ -2648,7 +2669,7 @@ fun PdfViewerScreen(
var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) }
var showSummarizationPopup by remember { mutableStateOf(false) }
var showAiHubSheet by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isSummarizationLoading by remember { mutableStateOf(false) }
@ -2659,17 +2680,18 @@ fun PdfViewerScreen(
val scrubDebounceJob = remember { mutableStateOf<Job?>(null) }
var startPageThumbnail by remember { mutableStateOf<Bitmap?>(null) }
val speakerPlayer =
remember(context, coroutineScope) { SpeakerSamplePlayer(context, coroutineScope) }
val speakerPlayer = remember(context, coroutineScope) {
SpeakerSamplePlayer(
context = context,
scope = coroutineScope,
getAuthToken = { viewModel.getAuthToken() }
)
}
var clickedLinkUrl by remember { mutableStateOf<String?>(null) }
val uriHandler = LocalUriHandler.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
ttsState.currentText
var showRenameBookmarkDialog by remember { mutableStateOf<PdfBookmark?>(null) }
var isOcrModelDownloading by remember { mutableStateOf(false) }
@ -2723,35 +2745,43 @@ fun PdfViewerScreen(
}
}
val onDictionaryLookupStable = remember(isProUser, executeWithOcrCheck, useOnlineDictionary, selectedDictPackage) {
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
{ text: String ->
executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(text)
if (isProUser || wordCount <= 1) {
val wordCount = com.aryan.reader.countWords(text)
if (wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = text
showAiDefinitionPopup = true
coroutineScope.launch {
val token = viewModel.getAuthToken()
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = text, onUpdate = { chunk ->
text = text,
authToken = token,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(
definition = currentDefinition + chunk
)
}, onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
}, onFinish = {
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiDefinitionPopup = false
isAiDefinitionLoading = false
}, context = context
} else {
aiDefinitionResult = AiDefinitionResult(error = error)
}
},
onFinish = { isAiDefinitionLoading = false },
context = context
)
}
} else {
showDictionaryUpsellDialog = true
}
} else {
if (!selectedDictPackage.isNullOrEmpty()) {
@ -2835,6 +2865,7 @@ fun PdfViewerScreen(
}
suspend fun summarizeCurrentPage(
authToken: String?,
onUpdate: (SummarizationResult) -> Unit, onFinish: () -> Unit
) {
val currentPageIndex = currentPage
@ -2892,28 +2923,50 @@ fun PdfViewerScreen(
put("content_type", "image")
put("data", base64Image)
}
if (authToken != null) {
connection.setRequestProperty("Authorization", "Bearer $authToken")
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
Timber.d("Summarization API response code: $responseCode")
if (responseCode == 402) {
onUpdate(SummarizationResult(error = "INSUFFICIENT_CREDITS"))
onFinish()
return@withContext
}
if (responseCode == HttpURLConnection.HTTP_OK) {
val fullText = StringBuilder()
var lastResult: SummarizationResult? = null
var currentCost: Double? = null
var currentFreeRemaining: Int? = null
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
try {
val jsonResponse = JSONObject(line!!)
val cost = if (jsonResponse.has("cost_deducted")) jsonResponse.optDouble("cost_deducted", -1.0) else -1.0
val freeRemaining = jsonResponse.optInt("free_summaries_remaining", -1)
if (cost > -1.0 || freeRemaining > -1) {
if (cost > -1.0) currentCost = cost
if (freeRemaining > -1) currentFreeRemaining = freeRemaining
lastResult = SummarizationResult(summary = fullText.toString(), cost = currentCost, freeRemaining = currentFreeRemaining)
onUpdate(lastResult)
}
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
fullText.append(it)
lastResult = SummarizationResult(summary = fullText.toString())
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION") onUpdate(lastResult!!)
lastResult = SummarizationResult(summary = fullText.toString(), cost = currentCost, freeRemaining = currentFreeRemaining)
onUpdate(lastResult!!)
}
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
lastResult = SummarizationResult(error = it)
lastResult = SummarizationResult(error = it, cost = currentCost, freeRemaining = currentFreeRemaining)
onUpdate(lastResult)
}
} catch (e: Exception) {
@ -3032,11 +3085,17 @@ fun PdfViewerScreen(
}
fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) {
if (currentTtsMode == TtsPlaybackManager.TtsMode.CLOUD && uiState.credits <= 0) {
showInsufficientCreditsDialog = true
return
}
Timber.d("TTS button clicked: Starting TTS for current page/selection")
if (pdfDocument == null || totalPages == 0) {
return
}
coroutineScope.launch {
val token = viewModel.getAuthToken()
val pageToRead = pageToReadOverride ?: currentPage
var rawPageText: String? = null
var tempPage: ReaderPage? = null
@ -3108,7 +3167,8 @@ fun PdfViewerScreen(
chapterTitle = pageTitle,
coverImageUri = null,
ttsMode = currentTtsMode,
playbackSource = "READER"
playbackSource = "READER",
authToken = token
)
if (isAutoPagingForTts) {
@ -3269,6 +3329,7 @@ fun PdfViewerScreen(
isLoadingDocument = true
isDocumentReady = false
errorMessage = null
documentMetadataTitle = null
if (showPasswordDialog) isPasswordError = false
@ -3336,6 +3397,7 @@ fun PdfViewerScreen(
}
pdfDocument = doc
documentMetadataTitle = (doc as? PdfDocumentWrapper)?.pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
pfdState = currentPfdOpened
val pagesCount = doc.getPageCount()
@ -3525,6 +3587,7 @@ fun PdfViewerScreen(
}
}
previousPage = currentPage
summarizationResult = null
}
LaunchedEffect(pagerState.currentPage) {
@ -3862,7 +3925,7 @@ fun PdfViewerScreen(
showBars = true
}
showSummarizationPopup -> showSummarizationPopup = false
showAiHubSheet -> showAiHubSheet = false
showPermissionRationaleDialog -> showPermissionRationaleDialog = false
showSummarizationUpsellDialog -> showSummarizationUpsellDialog = false
showAiDefinitionPopup -> showAiDefinitionPopup = false
@ -3991,7 +4054,54 @@ fun PdfViewerScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
val onScrollToCurrent = {
drawerScope.launch {
val targetEntry = currentTocEntry ?: return@launch
val targetOriginalIndex = flatTableOfContents.indexOf(targetEntry)
if (targetOriginalIndex != -1) {
var currentLevel = targetEntry.nestLevel
val newExpanded = expandedEntryIndices.toMutableSet()
for (i in targetOriginalIndex downTo 0) {
val entry = flatTableOfContents[i]
if (entry.nestLevel < currentLevel) {
newExpanded.add(i)
currentLevel = entry.nestLevel
}
}
expandedEntryIndices = newExpanded
delay(100)
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
if (visibleIdx != -1) {
listState.animateScrollToItem(visibleIdx)
}
}
}
Unit
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = { expandedEntryIndices = flatTableOfContents.indices.toSet() }) {
Text("Expand All")
}
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
Text("Collapse All")
}
TextButton(onClick = onScrollToCurrent) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = listState,
modifier = Modifier
@ -4043,6 +4153,7 @@ fun PdfViewerScreen(
}
}
}
}
1 -> { // Bookmarks Page
if (bookmarks.isEmpty()) {
@ -4723,6 +4834,7 @@ fun PdfViewerScreen(
},
richTextController = richTextController,
isStylusOnlyMode = isStylusOnlyMode,
isAutoScrollPlaying = isAutoScrollPlaying,
isHighlighterSnapEnabled = isHighlighterSnapEnabled,
isEditMode = isDrawingActive,
textBoxes = textBoxes.filter { it.pageIndex == pageIndex },
@ -5833,9 +5945,10 @@ fun PdfViewerScreen(
if (!hiddenTools.contains(PdfReaderTool.TTS_SETTINGS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsSessionActive,
onClick = {
showMoreMenu = false
showDeviceVoiceSettingsSheet = true
showTtsSettingsSheet = true
},
leadingIcon = {
Icon(
@ -5845,24 +5958,6 @@ fun PdfViewerScreen(
)
}
)
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
onClick = {
showMoreMenu = false
showTtsSettingsSheet = true
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.text_to_speech),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.BOOKMARK.name)) {
DropdownMenuItem(text = {
@ -6429,44 +6524,16 @@ fun PdfViewerScreen(
// AI feat
if (BuildConfig.FLAVOR != "oss" && !hiddenTools.contains(PdfReaderTool.AI_FEATURES.name)) {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_ai),
description = stringResource(R.string.tooltip_ai_desc),
onClick = { showAiFeaturesMenu = true }
onClick = { showAiHubSheet = true }
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = stringResource(R.string.tooltip_ai)
)
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }) {
DropdownMenuItem(
text = {
Text(stringResource(R.string.action_summarize_page))
}, onClick = {
showAiFeaturesMenu = false
if (isProUser) {
showSummarizationPopup = true
coroutineScope.launch {
isAiDefinitionLoading = true
summarizationResult = null
summarizeCurrentPage(onUpdate = { result ->
summarizationResult = result
}, onFinish = {
isAiDefinitionLoading = false
})
}
} else {
showSummarizationUpsellDialog = true
}
}, enabled = !isSummarizationLoading && pdfDocument != null
)
}
}
}
// Edit Button
@ -6506,8 +6573,6 @@ fun PdfViewerScreen(
// TTS
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
TooltipIconButton(
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
else stringResource(R.string.tooltip_tts_start),
@ -6523,56 +6588,11 @@ fun PdfViewerScreen(
}) {
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)
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
)
}
if (isTtsSessionActive) {
TooltipIconButton(
text = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause)
else
stringResource(R.string.tooltip_tts_resume),
description = if (ttsState.isPlaying)
stringResource(R.string.tooltip_tts_pause_desc)
else
stringResource(R.string.tooltip_tts_resume_desc),
onClick = {
if (ttsState.isPlaying) {
ttsController.pause()
} else {
ttsController.resume()
}
}, enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(
id = if (ttsState.isPlaying) R.drawable.pause
else R.drawable.play
), contentDescription = if (ttsState.isPlaying) stringResource(R.string.content_desc_pause_tts)
else stringResource(R.string.content_desc_resume_tts)
)
}
// Tune button for BASE mode
if (currentTtsMode == TtsPlaybackManager.TtsMode.BASE) {
TooltipIconButton(
text = stringResource(R.string.tts_voice_adjustments),
description = "Adjust voice speed and pitch",
onClick = { showTtsControlsSheet = true }
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = stringResource(R.string.tts_voice_adjustments)
)
}
}
}
}
}
}
// Error Message Area
@ -7190,15 +7210,71 @@ fun PdfViewerScreen(
}
}
if (showSummarizationPopup) {
SummarizationPopup(
title = "Page Summary",
result = summarizationResult,
isLoading = isSummarizationLoading,
onDismiss = { showSummarizationPopup = false },
isMainTtsActive = isTtsSessionActive
if (showAiHubSheet) {
val currentPageForDisplay = if (displayMode == DisplayMode.PAGINATION) {
pagerState.currentPage
} else {
verticalReaderState.currentPage
}
val bookTitle = documentMetadataTitle ?: originalFileName
AiHubBottomSheet(
bookTitle = bookTitle,
currentChapterIndex = currentPageForDisplay,
chapterTitle = "Page ${currentPageForDisplay + 1}",
summaryCacheManager = summaryCacheManager,
summarizationResult = summarizationResult,
isSummarizationLoading = isSummarizationLoading,
onClearSummary = { summarizationResult = null },
onGenerateSummary = { force ->
if (!isProUser && uiState.credits <= 0) {
showInsufficientCreditsDialog = true
showAiHubSheet = false
} else {
coroutineScope.launch {
isSummarizationLoading = true
summarizationResult = null
val cached = if (!force) summaryCacheManager.getSummary(bookTitle, currentPageForDisplay) else null
if (cached != null) {
summarizationResult = SummarizationResult(summary = cached, isCacheHit = true)
isSummarizationLoading = false
return@launch
}
val token = viewModel.getAuthToken()
summarizeCurrentPage(
authToken = token,
onUpdate = { result ->
if (result.error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiHubSheet = false
isSummarizationLoading = false
} else {
summarizationResult = result
}
}, onFinish = {
isSummarizationLoading = false
val finalSummary = summarizationResult?.summary
if (!finalSummary.isNullOrBlank() && summarizationResult?.error == null) {
summaryCacheManager.saveSummary(bookTitle, currentPageForDisplay, "Page ${currentPageForDisplay + 1}", finalSummary)
}
}
)
}
}
},
recapResult = null,
isRecapLoading = false,
onGenerateRecap = null,
onDismiss = { showAiHubSheet = false },
isMainTtsActive = isTtsSessionActive,
getAuthToken = { viewModel.getAuthToken() },
credits = uiState.credits,
isProUser = isProUser
)
}
if (showPermissionRationaleDialog) {
AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false },
@ -7254,6 +7330,26 @@ fun PdfViewerScreen(
})
}
if (showInsufficientCreditsDialog) {
AlertDialog(
onDismissRequest = { showInsufficientCreditsDialog = false },
icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) },
title = { Text("Out of Credits") },
text = { Text("You don't have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.") },
confirmButton = {
TextButton(onClick = {
showInsufficientCreditsDialog = false
onNavigateToPro()
}) { Text("Get Pro / Add Credits") }
},
dismissButton = {
TextButton(onClick = { showInsufficientCreditsDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showPasswordDialog) {
PasswordDialog(
isError = isPasswordError,
@ -7342,7 +7438,8 @@ fun PdfViewerScreen(
showDictionarySettingsSheet = true
}
}
}
},
getAuthToken = { viewModel.getAuthToken() }
)
}
if (showDictionaryUpsellDialog) {
@ -7455,6 +7552,7 @@ fun PdfViewerScreen(
}
if (showTtsSettingsSheet) {
val bookTitle = documentMetadataTitle ?: originalFileName
TtsSettingsSheet(
isVisible = true,
onDismiss = { showTtsSettingsSheet = false },
@ -7468,15 +7566,9 @@ fun PdfViewerScreen(
onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker)
},
isTtsActive = isTtsSessionActive
)
}
if (showTtsControlsSheet) {
TtsControlsSheet(
onDismiss = { showTtsControlsSheet = false },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
ttsController = ttsController
isTtsActive = isTtsSessionActive,
getAuthToken = { viewModel.getAuthToken() },
bookTitle = bookTitle
)
}
@ -7508,13 +7600,6 @@ fun PdfViewerScreen(
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (highlightToNoteId != null) {
val targetHighlight = userHighlights.find { it.id == highlightToNoteId }
if (targetHighlight != null) {
@ -7777,6 +7862,39 @@ fun PdfViewerScreen(
label = "AutoScrollPadding"
)
val ttsOverlayPadding by animateDpAsState(
targetValue = if (showBars) (56.dp + 16.dp) else 16.dp,
label = "TtsOverlayPadding"
)
val ttsAlignmentBias by animateFloatAsState(
targetValue = if (isTtsCollapsed) 1f else 0f,
label = "TtsAlignAnimation"
)
AnimatedVisibility(
visible = isTtsSessionActive && showBars,
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
modifier = Modifier
.align(BiasAlignment(ttsAlignmentBias, 1f))
.padding(bottom = ttsOverlayPadding)
.padding(horizontal = 16.dp)
) {
TtsOverlayControls(
ttsController = ttsController,
ttsState = ttsState,
currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it },
onOpenTtsSettings = { showTtsSettingsSheet = true },
onClose = {
ttsController.stop()
},
credits = uiState.credits
)
}
val isAutoScrollControlsVisible = isAutoScrollModeActive
val alignmentBias by animateFloatAsState(

View file

@ -37,6 +37,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.aryan.reader.BuildConfig
import com.aryan.reader.epubreader.loadTtsPitch
import com.aryan.reader.epubreader.loadTtsSpeechRate
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
@ -65,15 +67,12 @@ private fun loadSpeaker(context: Context): String {
}
@OptIn(UnstableApi::class)
@Suppress("KotlinConstantConditions")
fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.DEBUG &&
BuildConfig.IS_PRO &&
BuildConfig.TTS_WORKER_URL.isNotBlank()
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
return if (isCloudAllowed) {
try {
@ -159,7 +158,8 @@ class TtsController(context: Context) : Player.Listener {
chapterTitle: String?,
coverImageUri: String?,
ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER"
playbackSource: String = "READER",
authToken: String? = null
) {
if (chunks.isEmpty()) {
Timber.w("TtsController: start called with empty chunks!")
@ -181,19 +181,12 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_COVER_IMAGE_URI, coverImageUri)
putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken)
putFloat("playback_speed", loadTtsSpeechRate(context))
putFloat("playback_pitch", loadTtsPitch(context))
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
val metadataBuilder = androidx.media3.common.MediaMetadata.Builder()
.setArtist(bookTitle)
.setTitle(chapterTitle ?: "Reading Aloud")
coverImageUri?.let { metadataBuilder.setArtworkUri(it.toUri()) }
val metadata = MediaItem.Builder()
.setMediaId("tts_session")
.setMediaMetadata(metadataBuilder.build())
.build()
mediaController?.setMediaItem(metadata)
}
fun pause() {
@ -224,6 +217,10 @@ class TtsController(context: Context) : Player.Listener {
@Suppress("unused")
fun changeTtsMode(mode: String) {
Timber.d("UI sending CHANGE_TTS_MODE command.")
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString("tts_mode", mode) }
_ttsState.value = _ttsState.value.copy(ttsMode = mode)
val args = Bundle().apply {
putString(KEY_TTS_MODE, mode)
}
@ -261,6 +258,7 @@ class TtsController(context: Context) : Player.Listener {
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode)
val currentState = _ttsState.value
_ttsState.value = currentState.copy(
@ -288,11 +286,22 @@ class TtsController(context: Context) : Player.Listener {
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
sessionFinished = sessionFinished,
playbackSource = playbackSource
playbackSource = playbackSource,
ttsMode = serviceMode
)
}
}
fun setPlaybackParameters(speed: Float, pitch: Float) {
val args = Bundle().apply {
putFloat("speed", speed)
putFloat("pitch", pitch)
}
mediaController?.sendCustomCommand(SET_PLAYBACK_PARAMS_COMMAND, args)
}
fun release() {
pollingJob?.cancel()
scope.cancel()

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)
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
@ -68,20 +69,27 @@ const val KEY_TTS_MODE = "KEY_TTS_MODE"
const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
private const val PREFETCH_LOOKAHEAD = 2
private const val PREFETCH_LOOKAHEAD = 3
@UnstableApi
class TtsPlaybackManager(
private val player: Player,
private val generateAudioChunk: suspend (textChunk: String, speakerId: String, mode: TtsMode) -> TtsAudioData
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
private val onResetContext: () -> Unit
) : MediaSession.Callback, Player.Listener {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mediaSession: MediaSession? = null
private val prefetchingJobs = mutableMapOf<Int, Job>()
private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
private var wordTrackingJob: Job? = null
private var preparationJob: Job? = null
private var prefetchLoopJob: Job? = null
private var lastPrefetchIndex = -1
private var currentAuthToken: String? = null
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
enum class TtsMode {
CLOUD, BASE
@ -100,13 +108,14 @@ class TtsPlaybackManager(
val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1,
val sessionFinished: Boolean = false,
val playbackSource: String? = null
val playbackSource: String? = null,
val ttsMode: String = TtsMode.CLOUD.name
)
private val _ttsState = MutableStateFlow(TtsState())
private var textChunks: List<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 bookTitle: String? = null
private var chapterTitle: String? = null
@ -141,6 +150,7 @@ class TtsPlaybackManager(
.add(CHANGE_TTS_MODE_COMMAND)
.add(FLUSH_PREFETCH_COMMAND)
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
.add(SET_PLAYBACK_PARAMS_COMMAND)
.build()
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@ -192,7 +202,9 @@ class TtsPlaybackManager(
chunks.map { TtsChunk(it, "", -1) }
}
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource)
val authToken = args.getString(KEY_AUTH_TOKEN)
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource, args)
}
STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.")
@ -209,23 +221,55 @@ class TtsPlaybackManager(
}
FLUSH_PREFETCH_COMMAND -> {
Timber.d("Flushing prefetched TTS chunks for new parameters.")
onResetContext()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch(Dispatchers.IO) {
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
scope.launch(Dispatchers.Main) {
val currentIdx = player.currentMediaItemIndex
if (currentIdx == C.INDEX_UNSET) return@launch
val keysToRemove = audioFiles.keys.filter { it > currentIdx }
val keysToRemove = loadedChunks.filter { it > currentIdx }
withContext(Dispatchers.IO) {
keysToRemove.forEach { key ->
audioFiles.remove(key)?.delete()
loadedChunks.remove(key)
val file = audioFiles.remove(key)
deleteTempFile(file)
val streamId = chunkStreamIds.remove(key)
if (streamId != null) {
StreamRegistry.remove(streamId)
}
withContext(Dispatchers.Main) {
}
}
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 -> {
handleSliceAndReload()
}
SET_PLAYBACK_PARAMS_COMMAND -> {
val speed = args.getFloat("speed", 1f)
val pitch = args.getFloat("pitch", 1f)
if (currentTtsMode == TtsMode.CLOUD) {
scope.launch(Dispatchers.Main) {
player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch)
}
}
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
@ -234,6 +278,11 @@ class TtsPlaybackManager(
val currentIdx = player.currentMediaItemIndex
if (currentIdx == C.INDEX_UNSET) return
player.pause()
_ttsState.value = _ttsState.value.copy(isLoading = true)
onResetContext()
val offset = _ttsState.value.currentWordStartOffset
val currentChunk = textChunks.getOrNull(currentIdx) ?: return
@ -241,11 +290,14 @@ class TtsPlaybackManager(
wordTrackingJob?.cancel()
player.stop()
player.clearMediaItems()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
preparationJob = scope.launch {
clearAudioFiles()
loadedChunks.clear()
if (offset == -1) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
@ -275,6 +327,7 @@ class TtsPlaybackManager(
private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return
currentTtsMode = newMode
_ttsState.value = _ttsState.value.copy(ttsMode = newMode.name)
Timber.d("TTS Mode changed to $newMode (pending next start)")
}
@ -285,12 +338,29 @@ class TtsPlaybackManager(
chapterTitle: String?,
coverImageUri: String?,
ttsMode: TtsMode,
playbackSource: String?
playbackSource: String?,
args: Bundle // Added this parameter
) {
if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
return
}
// --- YOUR SNIPPET START ---
val authToken = args.getString(KEY_AUTH_TOKEN)
val speed = args.getFloat("playback_speed", 1f)
val pitch = args.getFloat("playback_pitch", 1f)
scope.launch(Dispatchers.Main) {
if (ttsMode == TtsMode.CLOUD) {
player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch)
} else {
player.playbackParameters = androidx.media3.common.PlaybackParameters(1f, 1f)
}
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStopTts(clearState = false)
textChunks = chunks
currentSpeakerId = speakerId
@ -298,13 +368,35 @@ class TtsPlaybackManager(
this.bookTitle = bookTitle
this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri
_ttsState.value = TtsState(isLoading = true, speakerId = speakerId, playbackSource = playbackSource)
onResetContext()
loadedChunks.clear()
lastPrefetchIndex = -1
_ttsState.value = TtsState(
isLoading = true,
speakerId = speakerId,
playbackSource = playbackSource,
ttsMode = ttsMode.name
)
currentAuthToken = authToken
preparationJob = scope.launch {
prepareAndPlayFirstChunk()
}
}
fun forceStopWithError(errorMessage: String) {
scope.launch(Dispatchers.Main) {
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = false,
errorMessage = errorMessage
)
handleStopTts(clearState = false)
}
}
private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return
currentSpeakerId = newSpeakerId
@ -319,27 +411,52 @@ class TtsPlaybackManager(
return
}
val ttsAudioData = generateAudioChunk(firstChunk.text, currentSpeakerId, currentTtsMode)
val chunkStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
withContext(Dispatchers.Main) {
_ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS")
handleStopTts(clearState = false)
}
return
}
val audioFile = ttsAudioData.audioFile
val streamUri = ttsAudioData.streamUri
val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) {
if ((audioFile != null || streamUri != null) && serverText != null) {
if (audioFile != null) {
audioFiles[startAtIndex] = audioFile
}
loadedChunks.add(startAtIndex)
val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings)
val mutableChunks = textChunks.toMutableList()
mutableChunks[startAtIndex] = updatedChunk
textChunks = mutableChunks.toList()
val mediaItem = createMediaItem(serverText, audioFile.absolutePath, startAtIndex, updatedChunk)
if (streamUri != null) {
val uriStr = streamUri.toUri()
val id = uriStr.host ?: uriStr.lastPathSegment
if (id != null) chunkStreamIds[startAtIndex] = id
}
val pathToUse = streamUri ?: audioFile!!.absolutePath
val mediaItem = createMediaItem(serverText, pathToUse, startAtIndex, updatedChunk)
withContext(Dispatchers.Main) {
val prepStartTime = System.currentTimeMillis()
player.setMediaItem(mediaItem)
player.prepare()
if (startAtPosition > 0) {
player.seekTo(startAtPosition)
}
player.playWhenReady = playWhenReady
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
@ -384,6 +501,8 @@ class TtsPlaybackManager(
}
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
onResetContext()
preparationJob?.cancel()
wordTrackingJob?.cancel()
if (clearState) {
@ -401,8 +520,11 @@ class TtsPlaybackManager(
player.stop()
player.clearMediaItems()
textChunks = emptyList()
lastPrefetchIndex = -1
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
loadedChunks.clear()
scope.launch {
clearAudioFiles()
@ -411,6 +533,7 @@ class TtsPlaybackManager(
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
@ -437,8 +560,14 @@ class TtsPlaybackManager(
val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull()
if (previousChunkIndex != null) {
scope.launch {
audioFiles.remove(previousChunkIndex)?.delete()
scope.launch(Dispatchers.IO) {
val file = audioFiles.remove(previousChunkIndex)
deleteTempFile(file)
loadedChunks.remove(previousChunkIndex)
val streamId = chunkStreamIds.remove(previousChunkIndex)
if (streamId != null) {
StreamRegistry.remove(streamId)
}
}
}
}
@ -485,48 +614,80 @@ class TtsPlaybackManager(
_ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (!nextState.sessionEndedByStop) {
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
handleStopTts(userInitiated = true)
} else {
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
}
}
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Timber.e(error, "Player error: ${error.message}")
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
handleStopTts(userInitiated = true)
}
private fun prefetchNextChunkAudio(currentIndex: Int) {
if (currentIndex == lastPrefetchIndex && prefetchLoopJob?.isActive == true) {
return
}
lastPrefetchIndex = currentIndex
prefetchLoopJob?.cancel()
prefetchLoopJob = scope.launch {
for (i in 1..PREFETCH_LOOKAHEAD) {
val targetIndex = currentIndex + i
if (targetIndex < textChunks.size) {
if (prefetchingJobs.containsKey(targetIndex)) {
continue
}
if (audioFiles.containsKey(targetIndex)) {
continue
}
if (prefetchingJobs.containsKey(targetIndex)) continue
if (audioFiles.containsKey(targetIndex)) continue
if (loadedChunks.contains(targetIndex)) continue
Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex")
val job = scope.launch {
val job = launch {
val nextChunk = textChunks[targetIndex]
val ttsAudioData = generateAudioChunk(nextChunk.text, currentSpeakerId, currentTtsMode)
val prefetchStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, targetIndex, textChunks.size, nextChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}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@launch
}
val audioFile = ttsAudioData.audioFile
val streamUri = ttsAudioData.streamUri
val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) {
audioFiles[targetIndex] = audioFile
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()
val nextMediaItem = createMediaItem(serverText, audioFile.absolutePath, targetIndex, updatedChunk)
withContext(Dispatchers.Main) {
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
@ -564,22 +725,58 @@ class TtsPlaybackManager(
job.invokeOnCompletion {
prefetchingJobs.remove(targetIndex)
}
job.join()
}
}
}
}
private suspend fun trackWordByWord() {
var loopCount = 0
while (true) {
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break
val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition }
if (loopCount % 20 == 0) {
withContext(Dispatchers.Main) { player.playbackState }
withContext(Dispatchers.Main) { player.isPlaying }
}
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 (playbackPosition >= expectedDurationMs) {
Timber.tag("TTS_CLOUD_DIAG").i("Stream finished naturally: pos=$playbackPosition, expected=$expectedDurationMs. Transitioning.")
withContext(Dispatchers.Main) {
if (player.currentMediaItemIndex == currentIdx) {
if (player.hasNextMediaItem()) {
player.seekToNextMediaItem()
} else {
player.stop()
}
}
}
break
}
}
}
}
val extras = currentMediaItem.mediaMetadata.extras ?: break
val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) ?: break
val offsets = extras.getIntArray(KEY_WORD_OFFSETS) ?: break
val sourceCfi = extras.getString("sourceCfi") ?: break
val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition }
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) {
@ -589,8 +786,19 @@ class TtsPlaybackManager(
)
}
}
delay(100)
}
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 {
@ -615,17 +823,30 @@ class TtsPlaybackManager(
.setExtras(extras)
.build()
val uri = if (path.startsWith("ttsstream://")) path.toUri() else Uri.fromFile(File(path))
return MediaItem.Builder()
.setUri(Uri.fromFile(File(path)))
.setUri(uri)
.setMediaId(index.toString())
.setMediaMetadata(metadata)
.build()
}
private fun deleteTempFile(file: File?) {
file?.let {
if (it.name.startsWith("tts_audio_chunk_") || it.name.startsWith("base_tts_") || it.name.startsWith("tts_live_")) {
it.delete()
}
}
}
private suspend fun clearAudioFiles() {
withContext(Dispatchers.IO) {
audioFiles.values.forEach { it.delete() }
audioFiles.values.forEach { deleteTempFile(it) }
audioFiles.clear()
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
chunkStreamIds.clear() // ADDED
loadedChunks.clear()
}
}
@ -640,6 +861,7 @@ class TtsPlaybackManager(
putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource)
putString("ttsMode", state.ttsMode)
}
return CommandButton.Builder()
.setSessionCommand(STATE_UPDATE_COMMAND)
@ -662,4 +884,15 @@ class TtsPlaybackManager(
handleStopTts(userInitiated = true)
Timber.d("TtsPlaybackManager released.")
}
override fun onPlaybackStateChanged(playbackState: Int) {
val stateName = when (playbackState) {
Player.STATE_IDLE -> "STATE_IDLE"
Player.STATE_BUFFERING -> "STATE_BUFFERING"
Player.STATE_READY -> "STATE_READY"
Player.STATE_ENDED -> "STATE_ENDED"
else -> "UNKNOWN"
}
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
}
}

View file

@ -23,8 +23,6 @@ import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Base64
import timber.log.Timber
import androidx.core.content.ContextCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@ -33,23 +31,33 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
data class WordTimingInfo(val word: String, val startTime: Double)
data class TtsAudioData(
val audioFile: File?,
val serverText: String?,
val wordTimings: List<WordTimingInfo>?
val wordTimings: List<WordTimingInfo>?,
val error: String? = null,
val streamUri: String? = null
)
data class PageCharacterRange(
@ -59,6 +67,165 @@ data class PageCharacterRange(
val endOffset: Int
)
class ConcurrentInputStream : java.io.InputStream() {
private val queue = java.util.concurrent.LinkedBlockingQueue<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
class TtsService : MediaSessionService() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@ -66,6 +233,7 @@ class TtsService : MediaSessionService() {
private lateinit var player: ExoPlayer
private lateinit var playbackManager: TtsPlaybackManager
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
private lateinit var cacheManager: TtsCacheManager
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
@ -81,104 +249,289 @@ class TtsService : MediaSessionService() {
super.onUpdateNotification(session, startInForegroundRequired)
}
/**
* Generic function to download TTS audio from a server endpoint.
* This is used for both the self-hosted server and the Google Cloud worker.
*
* @param chunkToSpeak The text to synthesize.
* @param speakerId The identifier for the voice.
* @param serverUrl The base URL of the TTS server.
* @param audioFileExtension The file extension for the temporary audio file (e.g., ".flac", ".mp3").
* @return A pair containing the temporary audio file and the text chunk returned by the server, or null if it fails.
*/
private suspend fun downloadFromTtsServer(
chunkToSpeak: String,
speakerId: String,
serverUrl: String,
audioFileExtension: String
): TtsAudioData {
if (chunkToSpeak.isBlank()) {
return TtsAudioData(null, null, null)
private val okHttpClient = OkHttpClient.Builder().build()
private val liveClient by lazy {
GeminiLiveClient(okHttpClient) { errorMsg ->
if (::playbackManager.isInitialized) {
playbackManager.forceStopWithError(errorMsg)
}
return withContext(Dispatchers.IO) {
var tempAudioFile: File? = null
}
}
class GeminiLiveClient(
private val client: OkHttpClient,
private val onAsyncError: (String) -> Unit = {}
) {
private var webSocket: WebSocket? = null
private val connectionMutex = Mutex()
private val generationMutex = Mutex()
private var clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var audioChannel = Channel<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 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 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 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 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))
}
}
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)
if (turnComplete) {
audioChannel.trySend(GeminiWsEvent.TurnComplete)
}
}
} catch (e: Exception) {
Timber.e(e, "DownloadAudioChunk: TTS Request Exception: ${e.message}")
tempAudioFile?.delete()
TtsAudioData(null, null, null)
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()
}
}
}
}
private val downloadAudioChunk: suspend (String, String) -> TtsAudioData =
{ chunkToSpeak, speakerId ->
downloadFromTtsServer(
chunkToSpeak,
speakerId,
googleCloudWorkerTtsUrl,
".mp3"
)
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 =
@ -187,10 +540,26 @@ class TtsService : MediaSessionService() {
TtsAudioData(file, text, null)
}
private val audioGenerator: suspend (text: String, speaker: String, mode: TtsMode) -> TtsAudioData =
{ text, speaker, mode ->
val audioGenerator: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, text: String, speaker: String, mode: TtsMode, authToken: String?) -> TtsAudioData =
{ bookTitle, chapterTitle, chunkIndex, totalChunks, text, speaker, mode, authToken ->
cacheManager.saveTotalChunks(bookTitle, chapterTitle, totalChunks)
when (mode) {
TtsMode.CLOUD -> downloadAudioChunk(text, speaker)
TtsMode.CLOUD -> {
val cachedFile = cacheManager.getCacheFile(bookTitle, chapterTitle, text, speaker, mode)
if (cachedFile.exists() && cachedFile.length() > 44) {
Timber.tag("TTS_CLOUD_DIAG").i("Using cached audio for chunk $chunkIndex")
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
} else {
try {
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
liveClient.generateChunk(text, cachedFile)
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
}
}
}
TtsMode.BASE -> synthesizeBaseTtsChunk(text)
}
}
@ -199,6 +568,8 @@ class TtsService : MediaSessionService() {
super.onCreate()
Timber.d("TtsService created.")
cacheManager = TtsCacheManager(this)
baseTtsSynthesizer = BaseTtsSynthesizer(this)
scope.launch {
try {
@ -213,14 +584,49 @@ class TtsService : MediaSessionService() {
.setUsage(C.USAGE_MEDIA)
.build()
val defaultDataSourceFactory = androidx.media3.datasource.DefaultDataSource.Factory(this)
val dataSourceFactory = androidx.media3.datasource.DataSource.Factory {
object : androidx.media3.datasource.DataSource {
private var dataSource: androidx.media3.datasource.DataSource? = null
private val defaultDataSource = defaultDataSourceFactory.createDataSource()
private val streamDataSource = InputStreamDataSource()
override fun addTransferListener(transferListener: androidx.media3.datasource.TransferListener) {
defaultDataSource.addTransferListener(transferListener)
streamDataSource.addTransferListener(transferListener)
}
override fun open(dataSpec: androidx.media3.datasource.DataSpec): Long {
dataSource = if (dataSpec.uri.scheme == "ttsstream") {
streamDataSource
} else {
defaultDataSource
}
return dataSource!!.open(dataSpec)
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
return dataSource!!.read(buffer, offset, length)
}
override fun getUri(): android.net.Uri? = dataSource?.uri
override fun close() {
dataSource?.close()
}
}
}
player = ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
.build()
playbackManager = TtsPlaybackManager(
player = player,
generateAudioChunk = audioGenerator
generateAudioChunk = audioGenerator,
onResetContext = { liveClient.close() }
)
mediaSession = MediaSession.Builder(this, player)

View file

@ -21,36 +21,189 @@ package com.aryan.reader.tts
import android.content.Context
import android.media.MediaPlayer
import timber.log.Timber
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import timber.log.Timber
import java.io.File
import java.io.RandomAccessFile
import java.security.MessageDigest
import kotlin.math.ln
import kotlin.math.pow
const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL
const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark."
const val TTS_CHUNK_MAX_LENGTH = 250
const val DEFAULT_SPEAKER_ID = "Aoede"
const val DEFAULT_SPEAKER_ID = "en-US-Standard-F"
data class GeminiVoice(val id: String, val name: String, val description: String)
@Suppress("unused")
val GOOGLE_TTS_SPEAKERS = listOf(
"US Female: F" to "en-US-Standard-F",
"US Female: H" to "en-US-Standard-H",
"US Male: I" to "en-US-Standard-I",
"US Male: J" to "en-US-Standard-J"
val GEMINI_TTS_SPEAKERS = listOf(
GeminiVoice("Zephyr", "Zephyr", "Bright, Higher pitch"),
GeminiVoice("Puck", "Puck", "Upbeat, Middle pitch"),
GeminiVoice("Charon", "Charon", "Informative, Lower pitch"),
GeminiVoice("Kore", "Kore", "Firm, Middle pitch"),
GeminiVoice("Fenrir", "Fenrir", "Excitable, Lower middle pitch"),
GeminiVoice("Leda", "Leda", "Youthful, Higher pitch"),
GeminiVoice("Orus", "Orus", "Firm, Lower middle pitch"),
GeminiVoice("Aoede", "Aoede", "Breezy, Middle pitch"),
GeminiVoice("Callirrhoe", "Callirrhoe", "Easy-going, Middle pitch"),
GeminiVoice("Autonoe", "Autonoe", "Bright, Middle pitch"),
GeminiVoice("Enceladus", "Enceladus", "Breathy, Lower pitch"),
GeminiVoice("Iapetus", "Iapetus", "Clear, Lower middle pitch"),
GeminiVoice("Umbriel", "Umbriel", "Easy-going, Lower middle pitch"),
GeminiVoice("Algieba", "Algieba", "Smooth, Lower pitch"),
GeminiVoice("Despina", "Despina", "Smooth, Middle pitch"),
GeminiVoice("Erinome", "Erinome", "Clear, Middle pitch"),
GeminiVoice("Algenib", "Algenib", "Gravelly, Lower pitch"),
GeminiVoice("Rasalgethi", "Rasalgethi", "Informative, Middle pitch"),
GeminiVoice("Laomedeia", "Laomedeia", "Upbeat, Higher pitch"),
GeminiVoice("Achernar", "Achernar", "Soft, Higher pitch"),
GeminiVoice("Alnilam", "Alnilam", "Firm, Lower middle pitch"),
GeminiVoice("Schedar", "Schedar", "Even, Lower middle pitch"),
GeminiVoice("Gacrux", "Gacrux", "Mature, Middle pitch"),
GeminiVoice("Pulcherrima", "Pulcherrima", "Forward, Middle pitch"),
GeminiVoice("Achird", "Achird", "Friendly, Lower middle pitch"),
GeminiVoice("Zubenelgenubi", "Zubenelgenubi", "Casual, Lower middle pitch"),
GeminiVoice("Vindemiatrix", "Vindemiatrix", "Gentle, Middle pitch"),
GeminiVoice("Sadachbia", "Sadachbia", "Lively, Lower pitch"),
GeminiVoice("Sadaltager", "Sadaltager", "Lively, Lower pitch"),
GeminiVoice("Sulafat", "Sulafat", "Warn, Middle pitch"),
)
data class TtsChapterCacheInfo(
val chapterTitle: String,
val chunkCount: Int,
val totalChunks: Int?,
val sizeBytes: Long,
val directory: File,
val matchingFiles: List<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> {
if (text.isBlank()) return emptyList()
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
}
@UnstableApi
class SpeakerSamplePlayer(
private val context: Context,
private val scope: CoroutineScope
private val scope: CoroutineScope,
private val getAuthToken: suspend () -> String?
) {
private val sampleMediaPlayer = MediaPlayer()
var loadingSpeakerId 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 {
// Read initially existing files
scope.launch(Dispatchers.IO) {
val files = context.cacheDir.listFiles { _, name -> name.startsWith("sample_") && name.endsWith(".wav") }
val ids = files?.map { it.name.removePrefix("sample_").removeSuffix(".wav") } ?: emptyList()
withContext(Dispatchers.Main) {
cachedSpeakers.addAll(ids)
}
}
sampleMediaPlayer.setOnErrorListener { mp, what, extra ->
Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.")
playingSpeakerId = null
loadingSpeakerId = null
try {
mp.reset()
} catch (e: IllegalStateException) {
Timber.e("Error resetting MediaPlayer: ${e.message}")
}
try { mp.reset() } catch (_: Exception) {}
true
}
}
@Suppress("unused")
fun playOrStop(speakerId: String) {
scope.launch {
liveClient.close()
when {
playingSpeakerId == speakerId -> {
sampleMediaPlayer.stop()
@ -122,51 +288,49 @@ class SpeakerSamplePlayer(
loadingSpeakerId == speakerId -> {
loadingSpeakerId = null
}
else -> playSample(speakerId)
else -> {
playSample(speakerId)
}
}
}
}
@OptIn(UnstableApi::class)
private suspend fun playSample(speakerId: String) {
if (sampleMediaPlayer.isPlaying) {
sampleMediaPlayer.stop()
}
if (sampleMediaPlayer.isPlaying) sampleMediaPlayer.stop()
sampleMediaPlayer.reset()
loadingSpeakerId = speakerId
playingSpeakerId = null
withContext(Dispatchers.IO) {
val cacheFile = File(context.cacheDir, "sample_$speakerId.wav")
try {
val url = URL(googleCloudWorkerTtsUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 15000
connection.readTimeout = 30000
connection.doOutput = true
connection.doInput = true
if (!cacheFile.exists()) {
val bucketName = "reader-9fc469d7.firebasestorage.app"
val sampleUrl = "https://firebasestorage.googleapis.com/v0/b/$bucketName/o/samples%2Fsample_${speakerId}.wav?alt=media"
val jsonPayload = JSONObject().apply {
put("text", TTS_SAMPLE_TEXT)
put("speaker", speakerId)
val request = okhttp3.Request.Builder()
.url(sampleUrl)
.build()
val response = httpClient.newCall(request).execute()
if (response.isSuccessful) {
response.body?.byteStream()?.use { input ->
cacheFile.outputStream().use { output ->
input.copyTo(output)
}
}
} else {
Timber.e("Failed to download sample for $speakerId. HTTP ${response.code}")
throw Exception("Failed to cache sample")
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val responseBody = connection.inputStream.bufferedReader().use { it.readText() }
val audioBase64 = JSONObject(responseBody).getString("audio_base64")
val dataUri = "data:audio/mpeg;base64,$audioBase64"
if (cacheFile.exists()) {
withContext(Dispatchers.Main) {
if (loadingSpeakerId != speakerId) {
return@withContext
}
sampleMediaPlayer.setDataSource(context, dataUri.toUri())
if (!cachedSpeakers.contains(speakerId)) cachedSpeakers.add(speakerId)
if (loadingSpeakerId != speakerId) return@withContext
sampleMediaPlayer.setDataSource(cacheFile.absolutePath)
sampleMediaPlayer.setOnPreparedListener { mp ->
if (loadingSpeakerId == speakerId) {
mp.start()
@ -180,16 +344,54 @@ class SpeakerSamplePlayer(
sampleMediaPlayer.prepareAsync()
}
} else {
Timber.e("Failed to fetch sample for $speakerId. Code: ${connection.responseCode}")
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
throw Exception("Sample file missing after download attempt")
}
} catch (e: Exception) {
Timber.e(e, "Exception playing sample for $speakerId: ${e.message}")
Timber.e(e, "Exception playing sample for $speakerId")
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
}
}
}
fun clearSamples() {
scope.launch(Dispatchers.IO) {
val files = context.cacheDir.listFiles { _, name -> name.startsWith("sample_") && name.endsWith(".wav") }
files?.forEach { it.delete() }
withContext(Dispatchers.Main) {
cachedSpeakers.clear()
}
}
}
@OptIn(UnstableApi::class)
fun release() {
sampleMediaPlayer.release()
liveClient.close()
}
}
fun createWavHeaderUnknownLength(sampleRate: Int): ByteArray {
val numChannels = 1
val bitsPerSample = 16
val byteRate = sampleRate * numChannels * bitsPerSample / 8
val blockAlign = numChannels * bitsPerSample / 8
val header = java.nio.ByteBuffer.allocate(44)
header.order(java.nio.ByteOrder.LITTLE_ENDIAN)
header.put("RIFF".toByteArray(Charsets.US_ASCII))
header.putInt(0x7FFFFFFF)
header.put("WAVE".toByteArray(Charsets.US_ASCII))
header.put("fmt ".toByteArray(Charsets.US_ASCII))
header.putInt(16)
header.putShort(1.toShort())
header.putShort(numChannels.toShort())
header.putInt(sampleRate)
header.putInt(byteRate)
header.putShort(blockAlign.toShort())
header.putShort(bitsPerSample.toShort())
header.put("data".toByteArray(Charsets.US_ASCII))
header.putInt(0x7FFFFFFF - 36)
return header.array()
}

View file

@ -245,19 +245,19 @@
<string name="feature_dict">Basic Dictionary</string>
<string name="feature_dict_desc">Look up single words quickly</string>
<string name="current_plan">Current Plan</string>
<!-- Promotional badge displayed on the Pro plan card. %% is a literal percent sign. -->
<string name="pro_sale_off" translatable="false">50%% OFF</string>
<!-- 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="loading_price">Loading price…</string>
<string name="one_time_payment">One-time payment</string>
<string name="lifetime_access">Lifetime Access</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>
<!-- "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>
<!-- "Summarization" is a Pro AI feature name. -->
<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. -->
<string name="feature_smart_dict">Smart Dictionary</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>
<!-- "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_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>
<!-- "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>
@ -511,7 +512,7 @@
<string name="search_in_book">Search in book…</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="action_stop">Stop</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?> {
return flowOf(null)
}
suspend fun getIdToken(): String? = null
}

View file

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

View file

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

View file

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