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:
parent
e8f6be2800
commit
46620fa71a
41 changed files with 4412 additions and 2406 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("\"", """)}</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("\"", """)}</title>\n<style>${cssStyle}</style>\n</head>\n<body>\n${bodyContent.trim()}\n</body>\n</html>"
|
||||
|
||||
file.writeText(fullHtml)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ abstract class BookCacheDao {
|
|||
ConfigurationCache::class,
|
||||
AnchorIndexEntry::class
|
||||
],
|
||||
version = 6,
|
||||
version = 7,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class BookCacheDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,4 +22,6 @@ class AuthRepository(private val applicationContext: Context) {
|
|||
fun observeAuthState(): Flow<UserData?> {
|
||||
return flowOf(null)
|
||||
}
|
||||
|
||||
suspend fun getIdToken(): String? = null
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue