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
|
|
@ -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,24 +473,8 @@ fun EpubReaderTopBar(
|
|||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
})
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_tts_settings_debug)) },
|
||||
onClick = {
|
||||
showMoreMenu = false
|
||||
onOpenTtsSettings()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.text_to_speech),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -514,15 +493,12 @@ fun EpubReaderBottomBar(
|
|||
ttsState: TtsState,
|
||||
isProUser: Boolean,
|
||||
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||
onOpenTtsControls: () -> Unit,
|
||||
onOpenSlider: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onToggleFormat: () -> Unit,
|
||||
onToggleSearch: () -> Unit,
|
||||
onSummarize: () -> Unit,
|
||||
onRecap: () -> Unit,
|
||||
onOpenAiHub: () -> Unit,
|
||||
onToggleTts: () -> Unit,
|
||||
onPlayPauseTts: () -> Unit,
|
||||
hiddenTools: Set<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -600,90 +576,36 @@ fun EpubReaderBottomBar(
|
|||
"KotlinConstantConditions",
|
||||
"SimplifyBooleanWithConstants"
|
||||
) if (BuildConfig.FLAVOR != "oss") {
|
||||
Box {
|
||||
var showAiFeaturesMenu by remember { mutableStateOf(false) }
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_ai),
|
||||
description = stringResource(R.string.tooltip_ai_desc),
|
||||
onClick = { showAiFeaturesMenu = true }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ai),
|
||||
contentDescription = "AI Features"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showAiFeaturesMenu,
|
||||
onDismissRequest = { showAiFeaturesMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_chapter_summarization)) },
|
||||
onClick = {
|
||||
showAiFeaturesMenu = false
|
||||
onSummarize()
|
||||
})
|
||||
if (BuildConfig.DEBUG && isProUser) {
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_recap_beta)) },
|
||||
onClick = {
|
||||
showAiFeaturesMenu = false
|
||||
onRecap()
|
||||
})
|
||||
}
|
||||
}
|
||||
TooltipIconButton(
|
||||
text = stringResource(R.string.tooltip_ai),
|
||||
description = stringResource(R.string.tooltip_ai_desc),
|
||||
onClick = onOpenAiHub
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ai),
|
||||
contentDescription = "AI Features"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hiddenTools.contains(ReaderTool.TTS_CONTROLS.name)) {
|
||||
Box {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TooltipIconButton(
|
||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
|
||||
else stringResource(R.string.tooltip_tts_start),
|
||||
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
|
||||
else stringResource(R.string.tooltip_tts_start_desc),
|
||||
onClick = onToggleTts
|
||||
) {
|
||||
Icon(
|
||||
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
|
||||
id = R.drawable.text_to_speech
|
||||
),
|
||||
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
|
||||
R.string.content_desc_start_tts
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isTtsSessionActive) {
|
||||
TooltipIconButton(
|
||||
text = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause)
|
||||
else stringResource(R.string.tooltip_tts_resume),
|
||||
description = if (ttsState.isPlaying) stringResource(R.string.tooltip_tts_pause_desc)
|
||||
else stringResource(R.string.tooltip_tts_resume_desc),
|
||||
onClick = onPlayPauseTts,
|
||||
enabled = !ttsState.isLoading
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = if (ttsState.isPlaying) stringResource(
|
||||
R.string.content_desc_pause_tts
|
||||
) else stringResource(R.string.content_desc_resume_tts)
|
||||
)
|
||||
}
|
||||
|
||||
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.BASE) {
|
||||
TooltipIconButton(
|
||||
text = "Voice Adjustments",
|
||||
description = "Adjust voice speed and pitch",
|
||||
onClick = onOpenTtsControls
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tune,
|
||||
contentDescription = "Voice Adjustments"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TooltipIconButton(
|
||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
|
||||
else stringResource(R.string.tooltip_tts_start),
|
||||
description = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop_desc)
|
||||
else stringResource(R.string.tooltip_tts_start_desc),
|
||||
onClick = onToggleTts
|
||||
) {
|
||||
Icon(
|
||||
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(
|
||||
id = R.drawable.text_to_speech
|
||||
),
|
||||
contentDescription = if (isTtsSessionActive) stringResource(R.string.content_desc_stop_tts) else stringResource(
|
||||
R.string.content_desc_start_tts
|
||||
),
|
||||
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1450,194 +1372,220 @@ fun CustomizeToolsSheet(
|
|||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TtsControlsSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onOpenDeviceVoiceSettings: () -> Unit,
|
||||
ttsController: com.aryan.reader.tts.TtsController
|
||||
fun TtsOverlayControls(
|
||||
ttsController: com.aryan.reader.tts.TtsController,
|
||||
ttsState: TtsState,
|
||||
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||
isCollapsed: Boolean,
|
||||
onCollapseChange: (Boolean) -> Unit,
|
||||
onOpenTtsSettings: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
credits: Int
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val ttsState by ttsController.ttsState.collectAsState()
|
||||
|
||||
// Local TTS for Sample Playback
|
||||
var tts by remember { mutableStateOf<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)
|
||||
ttsController.sliceAndRetainPosition()
|
||||
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
ttsController.setPlaybackParameters(rate, pitch)
|
||||
} else {
|
||||
ttsController.sliceAndRetainPosition()
|
||||
}
|
||||
}
|
||||
|
||||
val ttsSample = stringResource(R.string.tts_sample_text)
|
||||
val backgroundAlpha = 0.6f
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = backgroundAlpha),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
|
||||
modifier = modifier.widthIn(max = 400.dp).animateContentSize()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
||||
Text(stringResource(R.string.tts_voice_adjustments), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Rate Slider
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.tts_speed_label, "%.1f".format(rate)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
IconButton(onClick = {
|
||||
rate = 1.0f
|
||||
ttsController.pause()
|
||||
saveAndSlice()
|
||||
}) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Reset Speed")
|
||||
}
|
||||
}
|
||||
Slider(
|
||||
value = rate,
|
||||
onValueChange = {
|
||||
rate = it
|
||||
// Pause playback immediately when user starts dragging
|
||||
if (!isDraggingRate) {
|
||||
isDraggingRate = true
|
||||
ttsController.pause()
|
||||
}
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
isDraggingRate = false
|
||||
saveAndSlice()
|
||||
},
|
||||
valueRange = 0.5f..3.0f,
|
||||
steps = 24 // Creates 0.1 increments
|
||||
)
|
||||
|
||||
// Pitch Slider
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.tts_pitch_label, "%.1f".format(pitch)), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
IconButton(onClick = {
|
||||
pitch = 1.0f
|
||||
ttsController.pause()
|
||||
saveAndSlice()
|
||||
}) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Reset Pitch")
|
||||
}
|
||||
}
|
||||
Slider(
|
||||
value = pitch,
|
||||
onValueChange = {
|
||||
pitch = it
|
||||
if (!isDraggingPitch) {
|
||||
isDraggingPitch = true
|
||||
ttsController.pause()
|
||||
}
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
isDraggingPitch = false
|
||||
saveAndSlice()
|
||||
},
|
||||
valueRange = 0.5f..2.0f,
|
||||
steps = 14 // Creates 0.1 increments
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Play Sample Button
|
||||
Button(
|
||||
onClick = {
|
||||
if (ttsState.isPlaying) ttsController.pause()
|
||||
tts?.setSpeechRate(rate)
|
||||
tts?.setPitch(pitch)
|
||||
tts?.speak(ttsSample, TextToSpeech.QUEUE_FLUSH, null, null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isTtsReady,
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.GraphicEq, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Play Sample")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Central Play/Pause Control for the Book
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
tts?.stop()
|
||||
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
|
||||
},
|
||||
modifier = Modifier.size(64.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = isCollapsed,
|
||||
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
|
||||
label = "TtsOverlayUnified"
|
||||
) { collapsed ->
|
||||
if (collapsed) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onCollapseChange(false) },
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
if (ttsState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
strokeWidth = 3.dp
|
||||
Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
|
||||
modifier = Modifier.size(36.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = if (ttsState.isPlaying) stringResource(R.string.tts_pause_book) else stringResource(R.string.tts_resume_book),
|
||||
modifier = Modifier.size(32.dp)
|
||||
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
"Play/Pause",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
if (ttsState.isLoading) CircularProgressIndicator(
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (ttsState.isPlaying) stringResource(R.string.tts_pause_book) else stringResource(R.string.tts_resume_book),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) "✨ Cloud" else "📱 Device",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onOpenDeviceVoiceSettings()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.tts_system_settings))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
val voiceName = if (activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
GEMINI_TTS_SPEAKERS.find { it.id == ttsState.speakerId }?.name ?: ttsState.speakerId
|
||||
} else loadNativeVoice(context)?.split("-")?.lastOrNull() ?: "Default"
|
||||
|
||||
Text(
|
||||
voiceName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).widthIn(max = 100.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.FLAVOR != "oss" && activeMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.7f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"⭐ $credits",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Close, "Stop TTS", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Middle Section: Controls
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Giant Play/Pause
|
||||
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
"Play/Pause",
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
if (ttsState.isLoading) CircularProgressIndicator(
|
||||
modifier = Modifier.size(56.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
// Unified Sliders Block
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Spd: %.1fx".format(rate), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
|
||||
Slider(
|
||||
value = rate,
|
||||
onValueChange = {
|
||||
rate = it; if (!isDraggingRate && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
isDraggingRate = true; ttsController.pause()
|
||||
}
|
||||
},
|
||||
onValueChangeFinished = { isDraggingRate = false; saveAndApply() },
|
||||
valueRange = 0.5f..3.0f,
|
||||
steps = 24,
|
||||
modifier = Modifier.weight(1f).height(24.dp)
|
||||
)
|
||||
IconButton(onClick = { rate = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Refresh, "Reset Speed", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Ptch: %.1fx".format(pitch), style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(62.dp))
|
||||
Slider(
|
||||
value = pitch,
|
||||
onValueChange = {
|
||||
pitch = it; if (!isDraggingPitch && activeMode != com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD) {
|
||||
isDraggingPitch = true; ttsController.pause()
|
||||
}
|
||||
},
|
||||
onValueChangeFinished = { isDraggingPitch = false; saveAndApply() },
|
||||
valueRange = 0.5f..2.0f,
|
||||
steps = 14,
|
||||
modifier = Modifier.weight(1f).height(24.dp)
|
||||
)
|
||||
IconButton(onClick = { pitch = 1.0f; saveAndApply() }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Default.Refresh, "Reset Pitch", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,63 +354,113 @@ private fun ChaptersList(
|
|||
result
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxHeight().padding(end = 12.dp)
|
||||
) {
|
||||
items(
|
||||
items = visibleItemInfo,
|
||||
key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" }
|
||||
) { (originalIndex, entry) ->
|
||||
val nextItem = effectiveToc.getOrNull(originalIndex + 1)
|
||||
val hasChildren = nextItem != null && nextItem.depth > entry.depth
|
||||
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// HIGHLIGHT LOGIC FIXED
|
||||
val isCurrentPath = currentChapterPath == entry.absolutePath
|
||||
val matchesFragment = entry.fragmentId == activeFragmentId
|
||||
val activeTocEntry = remember(effectiveToc, currentChapterPath, activeFragmentId, firstEntryForCurrentChapter) {
|
||||
effectiveToc.find {
|
||||
it.absolutePath == currentChapterPath && it.fragmentId == activeFragmentId
|
||||
} ?: firstEntryForCurrentChapter
|
||||
}
|
||||
|
||||
// Fallback logic
|
||||
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter
|
||||
val isHighlighting = isCurrentPath && (matchesFragment || isFallback)
|
||||
|
||||
if (isCurrentPath) {
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback")
|
||||
}
|
||||
|
||||
if (isCurrentPath) {
|
||||
Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting")
|
||||
}
|
||||
|
||||
TocTreeItem(
|
||||
label = entry.label,
|
||||
depth = entry.depth,
|
||||
isExpanded = isExpanded,
|
||||
hasChildren = hasChildren,
|
||||
isCurrent = isHighlighting,
|
||||
onToggleExpand = {
|
||||
expandedEntryIndices = if (isExpanded) {
|
||||
expandedEntryIndices - originalIndex
|
||||
} else {
|
||||
expandedEntryIndices + originalIndex
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
if (tocEntries.isEmpty()) {
|
||||
onNavigateToChapter(originalIndex)
|
||||
} else {
|
||||
onNavigateToTocEntry(entry)
|
||||
}
|
||||
val onScrollToCurrent = {
|
||||
coroutineScope.launch {
|
||||
val targetEntry = activeTocEntry ?: return@launch
|
||||
val targetOriginalIndex = effectiveToc.indexOf(targetEntry)
|
||||
if (targetOriginalIndex != -1) {
|
||||
// Ensure parents are expanded
|
||||
var currentLevel = targetEntry.depth
|
||||
val newExpanded = expandedEntryIndices.toMutableSet()
|
||||
for (i in targetOriginalIndex downTo 0) {
|
||||
val entry = effectiveToc[i]
|
||||
if (entry.depth < currentLevel) {
|
||||
newExpanded.add(i)
|
||||
currentLevel = entry.depth
|
||||
}
|
||||
)
|
||||
}
|
||||
expandedEntryIndices = newExpanded
|
||||
|
||||
// Delay to allow visibility array to recompose
|
||||
kotlinx.coroutines.delay(100)
|
||||
|
||||
val visibleIdx = visibleItemInfo.indexOfFirst { it.second == targetEntry }
|
||||
if (visibleIdx != -1) {
|
||||
listState.animateScrollToItem(visibleIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
TextButton(onClick = { expandedEntryIndices = effectiveToc.indices.toSet() }) {
|
||||
Text("Expand All")
|
||||
}
|
||||
TextButton(onClick = { expandedEntryIndices = emptySet() }) {
|
||||
Text("Collapse All")
|
||||
}
|
||||
TextButton(onClick = onScrollToCurrent) {
|
||||
Text("Locate")
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
HorizontalDivider()
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(end = 12.dp)
|
||||
) {
|
||||
items(
|
||||
items = visibleItemInfo,
|
||||
key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" }
|
||||
) { (originalIndex, entry) ->
|
||||
val nextItem = effectiveToc.getOrNull(originalIndex + 1)
|
||||
val hasChildren = nextItem != null && nextItem.depth > entry.depth
|
||||
val isExpanded = expandedEntryIndices.contains(originalIndex)
|
||||
|
||||
val isCurrentPath = currentChapterPath == entry.absolutePath
|
||||
val matchesFragment = entry.fragmentId == activeFragmentId
|
||||
|
||||
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter
|
||||
val isHighlighting = isCurrentPath && (matchesFragment || isFallback)
|
||||
|
||||
TocTreeItem(
|
||||
label = entry.label,
|
||||
depth = entry.depth,
|
||||
isExpanded = isExpanded,
|
||||
hasChildren = hasChildren,
|
||||
isCurrent = isHighlighting,
|
||||
onToggleExpand = {
|
||||
expandedEntryIndices = if (isExpanded) {
|
||||
expandedEntryIndices - originalIndex
|
||||
} else {
|
||||
expandedEntryIndices + originalIndex
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
if (tocEntries.isEmpty()) {
|
||||
onNavigateToChapter(originalIndex)
|
||||
} else {
|
||||
onNavigateToTocEntry(entry)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
listState = listState,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
val currentDefinition = aiDefinitionResult?.definition ?: ""
|
||||
aiDefinitionResult =
|
||||
AiDefinitionResult(definition = currentDefinition + chunk)
|
||||
}, onError = { error ->
|
||||
aiDefinitionResult = AiDefinitionResult(error = error)
|
||||
}, onFinish = { isAiDefinitionLoading = false }, context = context
|
||||
text = word,
|
||||
onUpdate = { chunk ->
|
||||
val currentDefinition = aiDefinitionResult?.definition ?: ""
|
||||
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
|
||||
},
|
||||
authToken = token,
|
||||
onError = { error ->
|
||||
if (error == "INSUFFICIENT_CREDITS") {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiDefinitionPopup = false
|
||||
isAiDefinitionLoading = false
|
||||
} else {
|
||||
aiDefinitionResult = AiDefinitionResult(error = error)
|
||||
}
|
||||
},
|
||||
onFinish = { isAiDefinitionLoading = false },
|
||||
context = context
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showDictionaryUpsellDialog = true
|
||||
}
|
||||
} else {
|
||||
if (!selectedDictPackage.isNullOrEmpty()) {
|
||||
|
|
@ -728,10 +754,6 @@ fun EpubReaderHost(
|
|||
|
||||
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
|
||||
var showRecapPopup by remember { mutableStateOf(false) }
|
||||
var recapResult by remember { mutableStateOf<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,7 +991,12 @@ fun EpubReaderHost(
|
|||
|
||||
LaunchedEffect(ttsState.errorMessage) {
|
||||
ttsState.errorMessage?.let { message ->
|
||||
bannerMessage = BannerMessage(message, isError = true)
|
||||
if (message == "INSUFFICIENT_CREDITS") {
|
||||
showInsufficientCreditsDialog = true
|
||||
ttsController.stop()
|
||||
} else {
|
||||
bannerMessage = BannerMessage(message, isError = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -983,7 +1015,9 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
val searchState = rememberSearchState(scope = scope, searcher = epubSearcher)
|
||||
val speakerPlayer = remember(context, scope) { SpeakerSamplePlayer(context, scope) }
|
||||
val speakerPlayer = remember(context, scope) {
|
||||
SpeakerSamplePlayer(context, scope, getAuthToken = { viewModel.getAuthToken() })
|
||||
}
|
||||
|
||||
var isAutoScrollModeActive by remember { mutableStateOf(false) }
|
||||
var isAutoScrollPlaying by remember { mutableStateOf(false) }
|
||||
|
|
@ -1041,7 +1075,6 @@ fun EpubReaderHost(
|
|||
|
||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
||||
var showThemePanel by remember { mutableStateOf(false) }
|
||||
var showPaletteManager by remember { mutableStateOf(false) }
|
||||
|
|
@ -1102,6 +1135,11 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
fun startTts() {
|
||||
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
if (isAutoScrollModeActive) {
|
||||
isAutoScrollModeActive = false
|
||||
isAutoScrollPlaying = false
|
||||
|
|
@ -1114,6 +1152,7 @@ fun EpubReaderHost(
|
|||
webView = webViewRefForTts,
|
||||
onPaginatedStart = {
|
||||
scope.launch {
|
||||
val token = viewModel.getAuthToken()
|
||||
val currentPage = paginatedPagerState.currentPage
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val chapterIndex = bookPaginator?.findChapterIndexForPage(currentPage)
|
||||
|
|
@ -1136,7 +1175,8 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1153,8 +1193,14 @@ fun EpubReaderHost(
|
|||
)
|
||||
|
||||
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
|
||||
if (currentTtsMode == com.aryan.reader.tts.TtsPlaybackManager.TtsMode.CLOUD && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
return
|
||||
}
|
||||
|
||||
val action = {
|
||||
scope.launch {
|
||||
val token = viewModel.getAuthToken()
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
|
||||
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
|
||||
|
|
@ -1191,7 +1237,8 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1237,7 +1284,8 @@ fun EpubReaderHost(
|
|||
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
|
||||
userStoppedTts = userStoppedTts,
|
||||
scope = scope,
|
||||
currentTtsMode = currentTtsMode
|
||||
currentTtsMode = currentTtsMode,
|
||||
getAuthToken = { viewModel.getAuthToken() }
|
||||
)
|
||||
|
||||
TtsHighlightHandler(
|
||||
|
|
@ -1338,12 +1386,15 @@ fun EpubReaderHost(
|
|||
}
|
||||
|
||||
val runRecap = { chapterIdx: Int, charLimit: Int ->
|
||||
showRecapPopup = true
|
||||
showAiHubSheet = true
|
||||
isRecapLoading = true
|
||||
recapResult = null
|
||||
recapProgressMessage = "Checking past chapters..."
|
||||
|
||||
scope.launch {
|
||||
val token = viewModel.getAuthToken()
|
||||
var currentCost: Double? = null
|
||||
|
||||
executeRecapLogic(
|
||||
epubBook = epubBook,
|
||||
chapterIndex = chapterIdx,
|
||||
|
|
@ -1352,13 +1403,27 @@ fun EpubReaderHost(
|
|||
paginator = paginator,
|
||||
context = context,
|
||||
onProgressUpdate = { recapProgressMessage = it },
|
||||
onCostReceived = { cost ->
|
||||
currentCost = cost
|
||||
recapResult = recapResult?.copy(cost = cost) ?: SummarizationResult(cost = cost)
|
||||
},
|
||||
onResultUpdate = { chunk ->
|
||||
isRecapLoading = false
|
||||
val current = recapResult?.summary ?: ""
|
||||
recapResult = SummarizationResult(summary = current + chunk)
|
||||
recapResult = SummarizationResult(
|
||||
summary = current + chunk,
|
||||
cost = currentCost
|
||||
)
|
||||
},
|
||||
authToken = token,
|
||||
onError = { error ->
|
||||
recapResult = SummarizationResult(error = error)
|
||||
if (error == "INSUFFICIENT_CREDITS") {
|
||||
showInsufficientCreditsDialog = true
|
||||
showRecapPopup = false
|
||||
isRecapLoading = false
|
||||
} else {
|
||||
recapResult = SummarizationResult(error = error)
|
||||
}
|
||||
},
|
||||
onFinish = { isRecapLoading = false }
|
||||
)
|
||||
|
|
@ -2053,6 +2118,161 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
|
||||
val handleGenerateSummary: (Boolean) -> Unit = { force ->
|
||||
if (!isProUser && credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
} else {
|
||||
showAiHubSheet = true
|
||||
isSummarizationLoading = true
|
||||
summarizationResult = null
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
val cached = if (!force) summaryCacheManager.getSummary(
|
||||
epubBook.title,
|
||||
currentChapterIndex
|
||||
) else null
|
||||
if (cached != null) {
|
||||
summarizationResult =
|
||||
SummarizationResult(summary = cached, isCacheHit = true)
|
||||
isSummarizationLoading = false
|
||||
} else {
|
||||
webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result ->
|
||||
Timber.d("JS summarization request: $result")
|
||||
} ?: run {
|
||||
isSummarizationLoading = false
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "WebView not available.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderMode.PAGINATED -> {
|
||||
scope.launch {
|
||||
val currentPage = paginatedPagerState.currentPage
|
||||
val token = viewModel.getAuthToken()
|
||||
val chapterIndex =
|
||||
(paginator as? BookPaginator)?.findChapterIndexForPage(currentPage)
|
||||
|
||||
Timber.tag("POS_DIAG")
|
||||
.d("handleGenerateSummary (Paginated): currentPage=$currentPage -> resolved chapterIndex=$chapterIndex")
|
||||
|
||||
if (chapterIndex != null) {
|
||||
val cached = if (!force) summaryCacheManager.getSummary(
|
||||
epubBook.title,
|
||||
chapterIndex
|
||||
) else null
|
||||
if (cached != null) {
|
||||
summarizationResult =
|
||||
SummarizationResult(summary = cached, isCacheHit = true)
|
||||
isSummarizationLoading = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
val text = paginator?.getPlainTextForChapter(chapterIndex)
|
||||
if (!text.isNullOrBlank()) {
|
||||
var currentCost: Double? = null
|
||||
var currentFreeRemaining: Int? = null
|
||||
val finalSummaryBuilder = StringBuilder()
|
||||
summarizeBookContent(
|
||||
content = text,
|
||||
authToken = token,
|
||||
onUsageReceived = { cost, freeRemaining ->
|
||||
currentCost = cost
|
||||
currentFreeRemaining = freeRemaining
|
||||
summarizationResult = summarizationResult?.copy(
|
||||
cost = cost, freeRemaining = freeRemaining
|
||||
) ?: SummarizationResult(
|
||||
cost = cost,
|
||||
freeRemaining = freeRemaining
|
||||
)
|
||||
},
|
||||
onUpdate = { chunk ->
|
||||
finalSummaryBuilder.append(chunk)
|
||||
val currentSummary = summarizationResult?.summary ?: ""
|
||||
summarizationResult = SummarizationResult(
|
||||
summary = currentSummary + chunk,
|
||||
cost = currentCost,
|
||||
freeRemaining = currentFreeRemaining
|
||||
)
|
||||
},
|
||||
onError = { error ->
|
||||
if (error == "INSUFFICIENT_CREDITS") {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
isSummarizationLoading = false
|
||||
} else {
|
||||
summarizationResult =
|
||||
SummarizationResult(error = error)
|
||||
}
|
||||
},
|
||||
onFinish = {
|
||||
isSummarizationLoading = false
|
||||
val fullSummary = finalSummaryBuilder.toString()
|
||||
if (fullSummary.isNotBlank()) {
|
||||
val chapterTitle =
|
||||
chapters.getOrNull(chapterIndex)?.title
|
||||
?: "Chapter ${chapterIndex + 1}"
|
||||
summaryCacheManager.saveSummary(
|
||||
epubBook.title,
|
||||
chapterIndex,
|
||||
chapterTitle,
|
||||
fullSummary
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "Could not get chapter content.")
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
} else {
|
||||
summarizationResult =
|
||||
SummarizationResult(error = "Could not determine current chapter.")
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handleGenerateRecap: () -> Unit = {
|
||||
if (credits <= 0) {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
} else {
|
||||
showAiHubSheet = true
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
isRequestingRecapCfi = true
|
||||
webViewRefForTts?.evaluateJavascript(
|
||||
"javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
RenderMode.PAGINATED -> {
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val chapterIndex = currentChapterInPaginatedMode
|
||||
|
||||
if (bookPaginator != null && chapterIndex != null) {
|
||||
val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
|
||||
val currentPageInChapter = paginatedPagerState.currentPage - startPage
|
||||
val charsScrolled = bookPaginator.getCharactersScrolledInChapter(
|
||||
chapterIndex,
|
||||
currentPageInChapter
|
||||
)
|
||||
runRecap(chapterIndex, charsScrolled.toInt())
|
||||
} else {
|
||||
bannerMessage =
|
||||
BannerMessage("Wait for book to load fully.", isError = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
|
|
@ -2552,6 +2772,7 @@ fun EpubReaderHost(
|
|||
ttsScope = scope,
|
||||
onTtsTextReady = { jsonString ->
|
||||
scope.launch {
|
||||
val token = viewModel.getAuthToken()
|
||||
Timber.tag("TTS_LIST_DIAG").d("Vertical: Processing received JSON. Length: ${jsonString.length}") // Add this
|
||||
val ttsChunks = mutableListOf<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
|
||||
return@launch
|
||||
}
|
||||
|
||||
ttsShouldStartOnChapterLoad = false
|
||||
val chapterTitle =
|
||||
chapters.getOrNull(currentChapterIndex)?.title
|
||||
val chapterTitle = chapters.getOrNull(currentChapterIndex)?.title
|
||||
val coverUriString = coverImagePath?.let {
|
||||
Uri.fromFile(File(it)).toString()
|
||||
}
|
||||
|
|
@ -2600,7 +2826,8 @@ fun EpubReaderHost(
|
|||
chapterTitle = chapterTitle,
|
||||
coverImageUri = coverUriString,
|
||||
ttsMode = currentTtsMode,
|
||||
playbackSource = "READER"
|
||||
playbackSource = "READER",
|
||||
authToken = token
|
||||
)
|
||||
} else {
|
||||
Timber.w("No TTS chunks were created from JSON, not starting TTS."
|
||||
|
|
@ -2625,25 +2852,48 @@ fun EpubReaderHost(
|
|||
onContentReadyForSummarization = { content ->
|
||||
Timber.d("Content received for summarization")
|
||||
scope.launch {
|
||||
val token = viewModel.getAuthToken()
|
||||
val chapterIndexToSave = currentChapterIndex
|
||||
val bookTitleToSave = epubBook.title
|
||||
val finalSummaryBuilder = StringBuilder()
|
||||
|
||||
var currentCost: Double? = null
|
||||
var currentFreeRemaining: Int? = null
|
||||
|
||||
summarizeBookContent(
|
||||
content = content,
|
||||
authToken = token,
|
||||
onUsageReceived = { cost: Double?, freeRemaining: Int? ->
|
||||
currentCost = cost
|
||||
currentFreeRemaining = freeRemaining
|
||||
summarizationResult = summarizationResult?.copy(
|
||||
cost = cost, freeRemaining = freeRemaining
|
||||
) ?: SummarizationResult(cost = cost, freeRemaining = freeRemaining)
|
||||
},
|
||||
onUpdate = { chunk ->
|
||||
finalSummaryBuilder.append(chunk)
|
||||
val currentSummary = summarizationResult?.summary ?: ""
|
||||
summarizationResult = SummarizationResult(summary = currentSummary + chunk)
|
||||
summarizationResult = SummarizationResult(
|
||||
summary = currentSummary + chunk,
|
||||
cost = currentCost,
|
||||
freeRemaining = currentFreeRemaining
|
||||
)
|
||||
},
|
||||
onError = { error ->
|
||||
summarizationResult = SummarizationResult(error = error)
|
||||
if (error == "INSUFFICIENT_CREDITS") {
|
||||
showInsufficientCreditsDialog = true
|
||||
showAiHubSheet = false
|
||||
isRecapLoading = false
|
||||
} else {
|
||||
recapResult = SummarizationResult(error = error)
|
||||
}
|
||||
},
|
||||
onFinish = {
|
||||
isSummarizationLoading = false
|
||||
val fullSummary = finalSummaryBuilder.toString()
|
||||
if (fullSummary.isNotBlank()) {
|
||||
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, fullSummary)
|
||||
val chapterTitle = chapters.getOrNull(chapterIndexToSave)?.title ?: "Chapter ${chapterIndexToSave + 1}"
|
||||
summaryCacheManager.saveSummary(bookTitleToSave, chapterIndexToSave, chapterTitle, fullSummary)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -3594,7 +3844,6 @@ fun EpubReaderHost(
|
|||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
onOpenThemeSettings = { showThemePanel = true },
|
||||
onOpenVisualOptions = { showVisualOptionsSheet = true },
|
||||
onToggleReflow = if (onToggleReflow != null) {
|
||||
|
|
@ -3620,6 +3869,40 @@ fun EpubReaderHost(
|
|||
label = "AutoScrollAlignAnimation"
|
||||
)
|
||||
|
||||
val ttsOverlayPadding by animateDpAsState(
|
||||
targetValue = if (showBars) (bottomPadding + 45.dp + 16.dp) else 32.dp,
|
||||
label = "TtsOverlayPadding"
|
||||
)
|
||||
|
||||
val ttsAlignmentBias by animateFloatAsState(
|
||||
targetValue = if (isTtsCollapsed) 1f else 0f,
|
||||
label = "TtsAlignAnimation"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isTtsSessionActive && showBars,
|
||||
enter = slideInVertically(animationSpec = tween(200)) { it } + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutVertically(animationSpec = tween(200)) { it } + fadeOut(animationSpec = tween(200)),
|
||||
modifier = Modifier
|
||||
.align(BiasAlignment(ttsAlignmentBias, 1f))
|
||||
.padding(bottom = ttsOverlayPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
TtsOverlayControls(
|
||||
ttsController = ttsController,
|
||||
ttsState = ttsState,
|
||||
currentTtsMode = currentTtsMode,
|
||||
isCollapsed = isTtsCollapsed,
|
||||
onCollapseChange = { isTtsCollapsed = it },
|
||||
onOpenTtsSettings = { showTtsSettingsSheet = true },
|
||||
onClose = {
|
||||
userStoppedTts = true
|
||||
ttsController.stop()
|
||||
},
|
||||
credits = credits
|
||||
)
|
||||
}
|
||||
|
||||
val isAutoScrollControlsVisible = isAutoScrollModeActive
|
||||
|
||||
AnimatedVisibility(
|
||||
|
|
@ -3725,7 +4008,7 @@ fun EpubReaderHost(
|
|||
isProUser = isProUser,
|
||||
hiddenTools = hiddenTools,
|
||||
currentTtsMode = currentTtsMode,
|
||||
onOpenTtsControls = { showTtsControlsSheet = true },
|
||||
onOpenAiHub = { showAiHubSheet = true },
|
||||
onOpenSlider = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
|
|
@ -3768,90 +4051,6 @@ fun EpubReaderHost(
|
|||
showBars = true
|
||||
showFormatAdjustmentBars = false
|
||||
},
|
||||
onSummarize = {
|
||||
if (isProUser) {
|
||||
showSummarizationPopup = true
|
||||
isSummarizationLoading = true
|
||||
summarizationResult = null
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
webViewRefForTts?.evaluateJavascript("javascript:AiBridgeHelper.extractAndRelayTextForSummarization();") { result ->
|
||||
Timber.d("JS summarization request: $result")
|
||||
} ?: run {
|
||||
isSummarizationLoading = false
|
||||
summarizationResult = SummarizationResult(error = "WebView not available.")
|
||||
}
|
||||
}
|
||||
RenderMode.PAGINATED -> {
|
||||
scope.launch {
|
||||
val currentPage = paginatedPagerState.currentPage
|
||||
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(currentPage)
|
||||
if (chapterIndex != null) {
|
||||
val text = paginator?.getPlainTextForChapter(chapterIndex)
|
||||
if (!text.isNullOrBlank()) {
|
||||
summarizeBookContent(
|
||||
content = text,
|
||||
onUpdate = { chunk ->
|
||||
val currentSummary =
|
||||
summarizationResult?.summary
|
||||
?: ""
|
||||
summarizationResult =
|
||||
SummarizationResult(
|
||||
summary = currentSummary + chunk
|
||||
)
|
||||
},
|
||||
onError = { error ->
|
||||
summarizationResult =
|
||||
SummarizationResult(
|
||||
error = error
|
||||
)
|
||||
},
|
||||
onFinish = {
|
||||
isSummarizationLoading =
|
||||
false
|
||||
}
|
||||
)
|
||||
} else {
|
||||
summarizationResult = SummarizationResult(error = "Could not get chapter content.")
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
} else {
|
||||
summarizationResult = SummarizationResult(error = "Could not determine current chapter.")
|
||||
isSummarizationLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showSummarizationUpsellDialog = true
|
||||
}
|
||||
},
|
||||
onRecap = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
isRequestingRecapCfi = true
|
||||
webViewRefForTts?.evaluateJavascript("javascript:CfiBridge.onCfiExtracted(window.getCurrentCfi());", null)
|
||||
}
|
||||
RenderMode.PAGINATED -> {
|
||||
val bookPaginator = paginator as? BookPaginator
|
||||
val chapterIndex = currentChapterInPaginatedMode
|
||||
|
||||
if (bookPaginator != null && chapterIndex != null) {
|
||||
val startPage = bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0
|
||||
val currentPageInChapter = paginatedPagerState.currentPage - startPage
|
||||
|
||||
val charsScrolled = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
|
||||
|
||||
Timber.d("Paginated Mode: Chapter $chapterIndex, PageInChapter $currentPageInChapter")
|
||||
Timber.d("Paginated Mode: Chars Scrolled (Limit): $charsScrolled")
|
||||
|
||||
runRecap(chapterIndex, charsScrolled.toInt())
|
||||
} else {
|
||||
bannerMessage = BannerMessage("Wait for book to load fully.", isError = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleTts = {
|
||||
if (isTtsSessionActive) {
|
||||
Timber.d("TTS button clicked: Stopping TTS")
|
||||
|
|
@ -3874,9 +4073,6 @@ fun EpubReaderHost(
|
|||
}
|
||||
}
|
||||
},
|
||||
onPlayPauseTts = {
|
||||
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomPadding)
|
||||
|
|
@ -3922,27 +4118,21 @@ fun EpubReaderHost(
|
|||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
val effectiveCurrentChapterIndex = if (currentRenderMode == RenderMode.PAGINATED) {
|
||||
currentChapterInPaginatedMode ?: currentChapterIndex
|
||||
} else {
|
||||
currentChapterIndex
|
||||
}
|
||||
|
||||
EpubReaderAiOverlays(
|
||||
showSummarizationPopup = showSummarizationPopup,
|
||||
bookTitle = epubBook.title,
|
||||
summaryCacheManager = summaryCacheManager,
|
||||
summarizationResult = summarizationResult,
|
||||
isSummarizationLoading = isSummarizationLoading,
|
||||
onDismissSummarization = {
|
||||
showSummarizationPopup = false
|
||||
isSummarizationLoading = false
|
||||
summarizationResult = null
|
||||
},
|
||||
showSummarizationUpsellDialog = showSummarizationUpsellDialog,
|
||||
onDismissSummarizationUpsell = { showSummarizationUpsellDialog = false },
|
||||
|
||||
showRecapPopup = showRecapPopup,
|
||||
recapResult = recapResult,
|
||||
isRecapLoading = isRecapLoading,
|
||||
onDismissRecap = {
|
||||
showRecapPopup = false
|
||||
isRecapLoading = false
|
||||
recapResult = null
|
||||
},
|
||||
|
||||
showAiDefinitionPopup = showAiDefinitionPopup,
|
||||
selectedTextForAi = selectedTextForAi,
|
||||
aiDefinitionResult = aiDefinitionResult,
|
||||
|
|
@ -3962,12 +4152,31 @@ fun EpubReaderHost(
|
|||
isTtsSessionActive = isTtsSessionActive,
|
||||
onOpenExternalDictionary = { text ->
|
||||
if (!selectedDictPackage.isNullOrEmpty()) {
|
||||
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
|
||||
ExternalDictionaryHelper.launchDictionary(
|
||||
context,
|
||||
selectedDictPackage!!,
|
||||
text
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Select an offline dictionary first.",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
showDictionarySettingsSheet = true
|
||||
}
|
||||
}
|
||||
},
|
||||
getAuthToken = { viewModel.getAuthToken() },
|
||||
credits = credits,
|
||||
isProUser = isProUser,
|
||||
currentChapterIndex = effectiveCurrentChapterIndex,
|
||||
chapterTitle = chapters.getOrNull(effectiveCurrentChapterIndex)?.title ?: "Chapter ${effectiveCurrentChapterIndex + 1}",
|
||||
showAiHubSheet = showAiHubSheet,
|
||||
onGenerateSummary = handleGenerateSummary,
|
||||
onGenerateRecap = handleGenerateRecap,
|
||||
onDismissAiHub = { showAiHubSheet = false },
|
||||
onClearSummary = { summarizationResult = null },
|
||||
onClearRecap = { recapResult = null }
|
||||
)
|
||||
|
||||
if (isNavigatingToPosition) {
|
||||
|
|
@ -4087,8 +4296,8 @@ fun EpubReaderHost(
|
|||
highlightToNoteCfi = null
|
||||
},
|
||||
onCopy = {
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("Copied Text", targetHighlight.text)
|
||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Copied Text", targetHighlight.text)
|
||||
clipboardManager.setPrimaryClip(clip)
|
||||
highlightToNoteCfi = null
|
||||
},
|
||||
|
|
@ -4176,15 +4385,9 @@ fun EpubReaderHost(
|
|||
onSpeakerChange = { newSpeaker ->
|
||||
ttsController.changeSpeaker(newSpeaker)
|
||||
},
|
||||
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER"
|
||||
)
|
||||
}
|
||||
|
||||
if (showTtsControlsSheet) {
|
||||
TtsControlsSheet(
|
||||
onDismiss = { showTtsControlsSheet = false },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
ttsController = ttsController
|
||||
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER",
|
||||
getAuthToken = { viewModel.getAuthToken() },
|
||||
bookTitle = epubBook.title
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4227,13 +4430,6 @@ fun EpubReaderHost(
|
|||
)
|
||||
}
|
||||
|
||||
if (showDeviceVoiceSettingsSheet) {
|
||||
DeviceVoiceSettingsSheet(
|
||||
isVisible = true,
|
||||
onDismiss = { showDeviceVoiceSettingsSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showVisualOptionsSheet) {
|
||||
VisualOptionsSheet(
|
||||
systemUiMode = systemUiMode,
|
||||
|
|
@ -4297,6 +4493,26 @@ fun EpubReaderHost(
|
|||
)
|
||||
}
|
||||
|
||||
if (showInsufficientCreditsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showInsufficientCreditsDialog = false },
|
||||
icon = { Icon(painterResource(id = R.drawable.crown), contentDescription = null) },
|
||||
title = { Text("Out of Credits") },
|
||||
text = { Text("You don't have enough credits. Get Episteme Pro for 10 free Summaries per day, or add more credits to use Summaries, Cloud TTS and Story Recap.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showInsufficientCreditsDialog = false
|
||||
onNavigateToPro()
|
||||
}) { Text("Get Pro / Add Credits") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showInsufficientCreditsDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showPaletteManager) {
|
||||
PaletteManagerDialog(
|
||||
currentPalette = currentHighlightPalette,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue