Merge remote-tracking branch 'origin/main'

This commit is contained in:
Hosted Weblate 2026-05-20 19:55:01 +02:00
commit 10e18cf657
No known key found for this signature in database
GPG key ID: A3FAAA06E6569B4C
6 changed files with 281 additions and 61 deletions

View file

@ -1891,6 +1891,7 @@ fun DeviceVoicesTab(
val allLanguagesLabel = stringResource(R.string.filter_all)
var selectedLanguage by remember { mutableStateOf(allLanguagesLabel) }
var languageMenuExpanded by remember { mutableStateOf(false) }
val offlineNativeOnly = BuildConfig.IS_OFFLINE
DisposableEffect(Unit) {
val tts = TextToSpeech(context) { status ->
@ -1903,20 +1904,35 @@ fun DeviceVoicesTab(
onDispose { tts.shutdown() }
}
LaunchedEffect(allVoices, savedVoiceName, offlineNativeOnly) {
if (
offlineNativeOnly &&
savedVoiceName != null &&
allVoices.any { voice -> voice.name == savedVoiceName && voice.isNetworkConnectionRequired }
) {
savedVoiceName = null
saveNativeVoice(context, null)
}
}
if (isTtsLoading) {
Box(modifier = Modifier.fillMaxWidth().height(150.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
}
val languages = remember(allVoices) {
val list = listOf(allLanguagesLabel) + allVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted()
val selectableVoices = remember(allVoices, offlineNativeOnly) {
if (offlineNativeOnly) allVoices.filter { voice -> !voice.isNetworkConnectionRequired } else allVoices
}
val languages = remember(selectableVoices) {
val list = listOf(allLanguagesLabel) + selectableVoices.map { it.locale.displayLanguage }.filter { it.isNotBlank() }.distinct().sorted()
Timber.tag("TTS_DIAGNOSE").d("Languages list updated: size=${list.size}, items=$list")
list
}
val filteredVoices = remember(allVoices, selectedLanguage) {
if (selectedLanguage == allLanguagesLabel) allVoices
else allVoices.filter { it.locale.displayLanguage == selectedLanguage }
val filteredVoices = remember(selectableVoices, selectedLanguage) {
if (selectedLanguage == allLanguagesLabel) selectableVoices
else selectableVoices.filter { it.locale.displayLanguage == selectedLanguage }
}
val isBaseMode = currentMode == TtsPlaybackManager.TtsMode.BASE
@ -1934,12 +1950,19 @@ fun DeviceVoicesTab(
try {
val defaultLocale = Locale.getDefault()
language = defaultLocale
val fallbackVoice =
val fallbackVoice = if (offlineNativeOnly) {
voices.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
} ?: voices.firstOrNull { voice ->
!voice.isNetworkConnectionRequired
}
} else {
defaultVoice ?: voices.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
} ?: voices.firstOrNull { voice ->
voice.locale == defaultLocale
}
}
fallbackVoice?.let { voice = it }
} catch (e: Exception) {
Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice")

View file

@ -23,6 +23,8 @@ import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import com.aryan.reader.BuildConfig
import com.aryan.reader.epubreader.loadTtsPitch
import com.aryan.reader.epubreader.loadTtsSpeechRate
import com.aryan.reader.loadNativeVoice
@ -47,6 +49,38 @@ private const val START_TIMEOUT_RETRY_MS = 4000L
private const val PROCESS_TIMEOUT_MS = 15000L
private const val MAX_RETRY_ATTEMPTS = 3
internal fun resolveNativeTtsVoiceForBuild(
preferredVoiceName: String?,
defaultVoice: Voice?,
availableVoices: Collection<Voice>?,
defaultLocale: Locale,
isOfflineBuild: Boolean
): Voice? {
val voices = availableVoices.orEmpty()
val preferredVoice = preferredVoiceName
?.takeIf { it.isNotBlank() }
?.let { name -> voices.firstOrNull { it.name == name } }
if (preferredVoice != null && (!isOfflineBuild || !preferredVoice.isNetworkConnectionRequired)) {
return preferredVoice
}
val localeOfflineVoice = voices.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
}
val anyOfflineVoice = voices.firstOrNull { voice -> !voice.isNetworkConnectionRequired }
return if (isOfflineBuild) {
localeOfflineVoice
?: anyOfflineVoice
?: defaultVoice?.takeUnless { it.isNetworkConnectionRequired }
} else {
defaultVoice
?: localeOfflineVoice
?: voices.firstOrNull { voice -> voice.locale == defaultLocale }
}
}
class BaseTtsSynthesizer(private val context: Context) {
private var tts: TextToSpeech? = null
@ -149,51 +183,39 @@ class BaseTtsSynthesizer(private val context: Context) {
try {
val preferredVoiceName = loadNativeVoice(context)
val defaultLocale = Locale.getDefault()
val defaultVoice = tts?.defaultVoice
val availableVoices = tts?.voices
val targetVoice = resolveNativeTtsVoiceForBuild(
preferredVoiceName = preferredVoiceName,
defaultVoice = defaultVoice,
availableVoices = availableVoices,
defaultLocale = defaultLocale,
isOfflineBuild = BuildConfig.IS_OFFLINE
)
if (preferredVoiceName.isNullOrBlank()) {
val defaultLocale = Locale.getDefault()
try {
tts?.language = defaultLocale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to restore default language")
}
val defaultVoice = try {
tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
} ?: tts?.voices?.firstOrNull { voice ->
voice.locale == defaultLocale
}
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to query default voice")
null
}
if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) {
Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})")
tts?.voice = defaultVoice
} else {
Timber.d("BaseTts: Using engine default voice for locale $defaultLocale")
}
if (targetVoice == null) {
tts?.language = defaultLocale
Timber.w("BaseTts: No suitable local voice found for locale $defaultLocale.")
return
}
if (tts?.voice?.name == preferredVoiceName) return
if (
!preferredVoiceName.isNullOrBlank() &&
targetVoice.name != preferredVoiceName &&
BuildConfig.IS_OFFLINE
) {
Timber.w("BaseTts: Saved voice '$preferredVoiceName' requires network or is unavailable in offline build. Using ${targetVoice.name}.")
}
val availableVoices = tts?.voices
if (availableVoices != null) {
val targetVoice = availableVoices.find { it.name == preferredVoiceName }
if (targetVoice != null) {
Timber.d("BaseTts: Setting preferred voice to ${targetVoice.name} (${targetVoice.locale})")
try {
tts?.language = targetVoice.locale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to set language for voice")
}
tts?.voice = targetVoice
} else {
Timber.w("BaseTts: Preferred voice '$preferredVoiceName' not found in current engine.")
if (tts?.voice?.name != targetVoice.name) {
Timber.d("BaseTts: Setting native voice to ${targetVoice.name} (${targetVoice.locale})")
try {
tts?.language = targetVoice.locale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to set language for voice")
}
tts?.voice = targetVoice
}
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to apply preferred voice")

View file

@ -58,19 +58,66 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
?: TtsPlaybackManager.TtsMode.BASE.name
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
return resolveTtsModeForCurrentBuild(context, savedModeName)
}
return if (isCloudAllowed) {
try {
TtsPlaybackManager.TtsMode.valueOf(savedModeName)
} catch (_: Exception) {
TtsPlaybackManager.TtsMode.BASE
}
@OptIn(UnstableApi::class)
internal fun resolveTtsModeForCurrentBuild(
context: Context,
requestedModeName: String?
): TtsPlaybackManager.TtsMode {
return resolveTtsModeForBuild(
requestedModeName = requestedModeName,
isOfflineBuild = BuildConfig.IS_OFFLINE,
isProBuild = BuildConfig.IS_PRO,
workerUrl = BuildConfig.TTS_WORKER_URL,
byokCloudAvailable = isByokCloudTtsAvailable(context)
)
}
@OptIn(UnstableApi::class)
internal fun resolveTtsModeForBuild(
requestedModeName: String?,
isOfflineBuild: Boolean,
isProBuild: Boolean,
workerUrl: String,
byokCloudAvailable: Boolean
): TtsPlaybackManager.TtsMode {
val requestedMode = try {
TtsPlaybackManager.TtsMode.valueOf(
requestedModeName ?: TtsPlaybackManager.TtsMode.BASE.name
)
} catch (_: Exception) {
TtsPlaybackManager.TtsMode.BASE
}
if (requestedMode != TtsPlaybackManager.TtsMode.CLOUD) {
return requestedMode
}
return if (isCloudTtsAllowedForBuild(
isOfflineBuild = isOfflineBuild,
isProBuild = isProBuild,
workerUrl = workerUrl,
byokCloudAvailable = byokCloudAvailable
)
) {
TtsPlaybackManager.TtsMode.CLOUD
} else {
TtsPlaybackManager.TtsMode.BASE
}
}
internal fun isCloudTtsAllowedForBuild(
isOfflineBuild: Boolean,
isProBuild: Boolean,
workerUrl: String,
byokCloudAvailable: Boolean
): Boolean {
if (isOfflineBuild) return false
return (isProBuild && workerUrl.isNotBlank()) || byokCloudAvailable
}
@UnstableApi
class TtsController(context: Context) : Player.Listener {
@ -168,9 +215,10 @@ class TtsController(context: Context) : Player.Listener {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
return
}
Timber.d("UI sending START command with mode: $ttsMode")
val effectiveTtsMode = resolveTtsModeForCurrentBuild(context, ttsMode.name)
Timber.d("UI sending START command with mode: $effectiveTtsMode")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$effectiveTtsMode, requestedMode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
)
val textList = ArrayList(chunks.map { it.text })
@ -193,13 +241,13 @@ class TtsController(context: Context) : Player.Listener {
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
putBoolean(KEY_CONTINUE_SESSION, continueSession)
putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_TTS_MODE, effectiveTtsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken)
putFloat("playback_speed", loadTtsSpeechRate(context))
putFloat("playback_pitch", loadTtsPitch(context))
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $effectiveTtsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
val controller = mediaController
if (controller == null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
@ -240,12 +288,13 @@ class TtsController(context: Context) : Player.Listener {
@Suppress("unused")
fun changeTtsMode(mode: String) {
Timber.d("UI sending CHANGE_TTS_MODE command.")
val effectiveMode = resolveTtsModeForCurrentBuild(context, mode)
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString("tts_mode", mode) }
_ttsState.value = _ttsState.value.copy(ttsMode = mode)
prefs.edit { putString("tts_mode", effectiveMode.name) }
_ttsState.value = _ttsState.value.copy(ttsMode = effectiveMode.name)
val args = Bundle().apply {
putString(KEY_TTS_MODE, mode)
putString(KEY_TTS_MODE, effectiveMode.name)
}
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
}

View file

@ -444,7 +444,7 @@ class TtsPlaybackManager(
val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0)
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
val ttsMode = resolveTtsModeForCurrentBuild(appContext, ttsModeName)
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
@ -483,7 +483,7 @@ class TtsPlaybackManager(
}
CHANGE_TTS_MODE_COMMAND -> {
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
val newMode = resolveTtsModeForCurrentBuild(appContext, newModeName)
handleChangeTtsMode(newMode)
}
FLUSH_PREFETCH_COMMAND -> {