book-reader/app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
Aryan 46620fa71a
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)
2026-04-18 16:46:58 +05:30

333 lines
No EOL
12 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo(
val text: String,
val cfi: String,
val offset: Int
)
@Suppress("unused")
@OptIn(UnstableApi::class)
fun saveTtsMode(context: Context, mode: TtsMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
/**
* Helper to update the WebView auto-scroll state.
*/
fun updateAutoScrollJs(webView: WebView?, playing: Boolean, speed: Float) {
if (playing) {
val jsCommand = "javascript:window.autoScroll.start($speed);"
webView?.evaluateJavascript(jsCommand, null)
} else {
webView?.evaluateJavascript("javascript:window.autoScroll.stop();", null)
}
}
/**
* Logic for triggering the actual TTS start based on the current mode.
*/
fun initiateTtsPlayback(
renderMode: RenderMode,
webView: WebView?,
onPaginatedStart: () -> Unit
) {
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.d("Vertical: requesting text extraction via JS.")
webView?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}
RenderMode.PAGINATED -> {
onPaginatedStart()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
@Composable
fun TtsSessionObserver(
ttsState: TtsPlaybackManager.TtsState,
ttsController: TtsController,
currentRenderMode: RenderMode,
chapters: List<EpubChapter>,
epubBookTitle: String,
coverImagePath: String?,
// Vertical Mode Dependencies
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
// Paginated Mode Dependencies
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
onTtsChapterIndexChange: (Int?) -> Unit,
onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean,
scope: CoroutineScope,
currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
handleVerticalAutoAdvance(
webViewRef = webViewRef,
loadedChunkCount = loadedChunkCount,
totalChunksInChapter = totalChunksInChapter,
currentTtsChapterIndex = ttsChapterIndex,
totalChapters = chapters.size,
onNavigateToNextChapter = { nextIndex ->
onToggleTtsStartOnLoad(true)
onNavigateToChapter(nextIndex)
},
onStopTts = { onTtsChapterIndexChange(null) }
)
} else if (currentRenderMode == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
ttsController = ttsController,
paginator = paginator,
pagerState = pagerState,
chapters = chapters,
currentTtsChapterIndex = ttsChapterIndex,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
// Playback stopped/paused
if (userStoppedTts || sessionEndedByStop) {
Timber.d("TTS stopped by user/stop command.")
onTtsChapterIndexChange(null)
}
}
}
prevTtsState.value = ttsState
}
}
/**
* Handles highlighting text in WebView (Vertical) or turning pages (Paginated)
* based on playback progress.
*/
@OptIn(UnstableApi::class)
@Composable
fun TtsHighlightHandler(
ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode,
webViewRef: WebView?,
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
scope: CoroutineScope
) {
// 1. Vertical & General Highlighting (WebView)
LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) {
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
val offset = ttsState.startOffsetInSource
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
// Use window.highlightFromCfi defined in epub_reader.js
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
webViewRef?.evaluateJavascript(jsCommand, null)
} else {
if (!ttsState.isPlaying && !ttsState.isLoading) {
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
}
}
}
// 2. Paginated Page Turning (Sentence/Fragment level)
LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) {
if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect
val cfi = ttsState.sourceCfi ?: return@LaunchedEffect
val offset = ttsState.startOffsetInSource.takeIf { it != -1 } ?: return@LaunchedEffect
val chapterIdx = ttsChapterIndex ?: return@LaunchedEffect
val pag = paginator ?: return@LaunchedEffect
val targetPage = pag.findPageForCfiAndOffset(chapterIdx, cfi, offset)
if (targetPage != null && targetPage != pagerState.currentPage) {
scope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
}
}
// --- Internal Helper Functions ---
private fun handleVerticalAutoAdvance(
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
currentTtsChapterIndex: Int?,
totalChapters: Int,
onNavigateToNextChapter: (Int) -> Unit,
onStopTts: () -> Unit
) {
if (loadedChunkCount < totalChunksInChapter) {
Timber.d("Vertical: Loading next chunk for TTS.")
webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}, 500)
} else {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) {
Timber.d("Vertical: Chapter finished, moving to next.")
onNavigateToNextChapter(currentTtsChapterIndex + 1)
} else {
Timber.d("Vertical: End of book.")
onStopTts()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
private fun handlePaginatedAutoAdvance(
ttsController: TtsController,
paginator: IPaginator?,
pagerState: PagerState,
chapters: List<EpubChapter>,
currentTtsChapterIndex: Int?,
epubBookTitle: String,
coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope,
ttsMode: TtsMode,
getAuthToken: suspend () -> String?
) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = currentTtsChapterIndex + 1
var foundContent = false
val bookPaginator = paginator as? BookPaginator
if (bookPaginator == null) {
onUpdateTtsChapter(null)
return@launch
}
while (chapterToTry < chapters.size) {
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
delay(300)
}
val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry)
if (!nextChapterChunks.isNullOrEmpty()) {
Timber.d("Paginated: Found content in chapter $chapterToTry. Starting.")
onUpdateTtsChapter(chapterToTry)
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,
playbackSource = "READER",
authToken = token
)
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {
pagerState.animateScrollToPage(targetPage!! + i)
delay(400)
}
}
chapterToTry++
}
}
if (!foundContent) {
Timber.d("Paginated: No more content found.")
onUpdateTtsChapter(null)
}
}
} else {
onUpdateTtsChapter(null)
}
}