Tts upgrade (#25)

* enable cloud tts but only in pro(flavor) debug mode for testing

* Added a device voice settings sheet to allow users to select and preview system-level TTS voices.

* Refactored `DeviceVoiceSettingsSheet` to improve voice selection and filtering, and updated `BaseTtsSynthesizer` for better language handling.
This commit is contained in:
Aryan 2026-03-04 10:16:32 +05:30 committed by GitHub
parent 1ce353ad44
commit 3dc1843801
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 869 additions and 150 deletions

View file

@ -72,6 +72,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu
@ -145,6 +146,8 @@ fun EpubReaderTopBar(
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
) {
@ -268,6 +271,33 @@ fun EpubReaderTopBar(
onStartAutoScroll()
}
)
HorizontalDivider()
// *** ADDITION START ***
DropdownMenuItem(
text = { Text("TTS Voice Settings") },
onClick = {
showMoreMenu = false
onOpenDeviceVoiceSettings()
},
leadingIcon = {
Icon(Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(20.dp))
}
)
if (BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text("TTS Settings (Debug)") },
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(painter = painterResource(id = R.drawable.text_to_speech), contentDescription = null, modifier = Modifier.size(20.dp))
}
)
}
}
}
}

View file

@ -127,10 +127,12 @@ import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BannerMessage
import com.aryan.reader.BuildConfig
import com.aryan.reader.CustomTopBanner
import com.aryan.reader.DeviceVoiceSettingsSheet
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
@ -150,7 +152,7 @@ import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import kotlinx.coroutines.Job
@ -177,6 +179,7 @@ private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
private const val TTS_MODE_KEY = "tts_mode"
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -229,6 +232,11 @@ private fun loadAutoScrollUseSlider(context: Context): Boolean {
return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false)
}
private fun saveTtsMode(context: Context, modeName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, modeName) }
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -325,6 +333,8 @@ fun EpubReaderHost(
mutableStateOf(loadPageTurnAnimationSetting(context))
}
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
val locatorConverter = remember(context) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
@ -689,7 +699,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
}
@ -708,6 +718,8 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
TtsSessionObserver(
ttsState = ttsState,
@ -731,7 +743,8 @@ fun EpubReaderHost(
},
onToggleTtsStartOnLoad = { shouldStart -> ttsShouldStartOnChapterLoad = shouldStart },
userStoppedTts = userStoppedTts,
scope = scope
scope = scope,
currentTtsMode = currentTtsMode
)
TtsHighlightHandler(
@ -1917,7 +1930,7 @@ fun EpubReaderHost(
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = TtsMode.BASE.name,
ttsMode = currentTtsMode,
playbackSource = "READER"
)
} else {
@ -2741,7 +2754,9 @@ fun EpubReaderHost(
showBars = true
},
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter)
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true }
)
val autoScrollPadding by androidx.compose.animation.core.animateDpAsState(
@ -3206,6 +3221,31 @@ fun EpubReaderHost(
PageScrubbingAnimation(currentPage = sliderCurrentPage.roundToInt(), totalPages = total)
}
if (showTtsSettingsSheet) {
TtsSettingsSheet(
isVisible = true,
onDismiss = { showTtsSettingsSheet = false },
currentMode = currentTtsMode,
onModeChange = { newMode ->
currentTtsMode = newMode
saveTtsMode(context, newMode.name)
ttsController.changeTtsMode(newMode.name)
},
currentSpeakerId = ttsState.speakerId,
onSpeakerChange = { newSpeaker ->
ttsController.changeSpeaker(newSpeaker)
},
isTtsActive = (ttsState.isPlaying || ttsState.isLoading) && ttsState.playbackSource == "READER"
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,
onDismiss = { showDeviceVoiceSettingsSheet = false }
)
}
if (showFontSelectionSheet) {
ModalBottomSheet(
onDismissRequest = { showFontSelectionSheet = false },

View file

@ -45,7 +45,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo(
@ -61,13 +60,6 @@ fun saveTtsMode(context: Context, mode: TtsMode) {
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@Suppress("unused")
@OptIn(UnstableApi::class)
fun loadTtsMode(): TtsMode {
// For this release, Cloud TTS is disabled. Force BASE mode.
return TtsMode.BASE
}
/**
* Helper to update the WebView auto-scroll state.
*/
@ -121,20 +113,20 @@ fun TtsSessionObserver(
onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean,
scope: CoroutineScope
scope: CoroutineScope,
currentTtsMode: TtsMode
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val isChangingConfig = ttsState.isChangingConfig
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (!isChangingConfig && isReaderSource) {
if (isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
@ -161,7 +153,8 @@ fun TtsSessionObserver(
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope
scope = scope,
ttsMode = currentTtsMode
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -271,14 +264,14 @@ private fun handlePaginatedAutoAdvance(
epubBookTitle: String,
coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope
scope: CoroutineScope,
ttsMode: TtsMode
) {
val lastPlayedChapter = currentTtsChapterIndex
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = lastPlayedChapter + 1
var chapterToTry = currentTtsChapterIndex + 1
var foundContent = false
val bookPaginator = paginator as? BookPaginator
@ -288,7 +281,6 @@ private fun handlePaginatedAutoAdvance(
}
while (chapterToTry < chapters.size) {
// Visually scroll to start of chapter
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
@ -309,13 +301,12 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = "BASE" // Defaulting to Base for safety
ttsMode = ttsMode
)
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
// Visually flip through empty pages if needed
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {