General improvements (#164)
* Optimized PDF tile rendering performance and quality. * Added support for adjusting TTS voice speed and pitch. * Updated the TTS settings UI and playback logic to support real-time parameter adjustments. * Added horizontal scrolling to the PDF viewer bottom toolbar and updated tool arrangement to use fixed spacing. * Refined cross-page selection and text extraction in `PaginatedReader` * Updated EPUB reader styling logic to refine typography and layout controls. * Bump version to 1.0.42(43)
This commit is contained in:
parent
12d50bb68d
commit
17a0097d4a
13 changed files with 559 additions and 168 deletions
|
|
@ -30,8 +30,8 @@ android {
|
|||
applicationId = "com.aryan.reader"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 42
|
||||
versionName = "1.0.41"
|
||||
versionCode = 43
|
||||
versionName = "1.0.42"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
externalNativeBuild {
|
||||
|
|
|
|||
|
|
@ -481,83 +481,60 @@
|
|||
var newGap = parseFloat(paragraphGap);
|
||||
|
||||
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
|
||||
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.6;
|
||||
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
|
||||
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
|
||||
|
||||
var fontCss = "";
|
||||
var selector = "body";
|
||||
|
||||
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
||||
var fallback = "sans-serif";
|
||||
|
||||
if (fontFamily === "Merriweather" || fontFamily === "Lora") {
|
||||
fallback = "serif";
|
||||
} else if (fontFamily === "Roboto Mono") {
|
||||
fallback = "monospace";
|
||||
}
|
||||
|
||||
selector = "body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th";
|
||||
fontCss = "font-family: '" + fontFamily + "', " + fallback + " !important;";
|
||||
fontCss = `
|
||||
body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
|
||||
font-family: '${fontFamily}', ${fallback} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- ALIGNMENT LOGIC ---
|
||||
var alignCss = "";
|
||||
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
|
||||
|
||||
if (textAlign === "left") {
|
||||
alignCss =
|
||||
` ` +
|
||||
alignSelector +
|
||||
` {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
`;
|
||||
alignCss = alignSelector + " { text-align: left !important; }";
|
||||
} else if (textAlign === "justify") {
|
||||
alignCss =
|
||||
` ` +
|
||||
alignSelector +
|
||||
` {
|
||||
text-align: justify !important;
|
||||
-webkit-hyphens: auto !important;
|
||||
hyphens: auto !important;
|
||||
}
|
||||
|
||||
`;
|
||||
alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
|
||||
}
|
||||
|
||||
// --- GAP LOGIC ---
|
||||
var gapCss = `
|
||||
var gapCss = "";
|
||||
if (newGap !== 1.0) {
|
||||
gapCss = `
|
||||
body p, body ul, body ol, body blockquote {
|
||||
margin-top: ` + (0.5 * newGap) + `em !important;
|
||||
margin-bottom: ` + (0.5 * newGap) + `em !important;
|
||||
margin-top: ${newGap}em !important;
|
||||
margin-bottom: ${newGap}em !important;
|
||||
}
|
||||
body li {
|
||||
margin-bottom: ` + (0.25 * newGap) + `em !important;
|
||||
margin-bottom: ${0.5 * newGap}em !important;
|
||||
}
|
||||
`;
|
||||
`;
|
||||
}
|
||||
|
||||
dynamicStyleElement.innerHTML =
|
||||
` body {
|
||||
font-size: ` +
|
||||
newFontSize +
|
||||
`em !important;
|
||||
line-height: ` +
|
||||
newLineHeight +
|
||||
` !important;
|
||||
var sizeCss = "";
|
||||
if (newFontSize !== 1.0) {
|
||||
sizeCss = `body { font-size: ${newFontSize}em !important; }`;
|
||||
}
|
||||
|
||||
var lineHeightCss = "";
|
||||
if (newLineHeight !== 1.0) {
|
||||
lineHeightCss = `
|
||||
body, p, div, span, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
|
||||
line-height: ${newLineHeight} !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
` +
|
||||
selector +
|
||||
` {
|
||||
` +
|
||||
fontCss +
|
||||
`
|
||||
}
|
||||
|
||||
` +
|
||||
alignCss +
|
||||
gapCss;
|
||||
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n");
|
||||
|
||||
setTimeout(
|
||||
function () {
|
||||
|
|
@ -565,29 +542,20 @@
|
|||
console.log(logTag + ": [BODY] Computed font-family: " + computedBody);
|
||||
|
||||
var firstPara = document.querySelector("p");
|
||||
|
||||
if (firstPara) {
|
||||
var computedPara = window.getComputedStyle(firstPara).fontFamily;
|
||||
var computedAlign = window.getComputedStyle(firstPara).textAlign;
|
||||
console.log(logTag + ": [PARAGRAPH] Computed font-family: " + computedPara);
|
||||
console.log(logTag + ": [PARAGRAPH] Inner Text Sample: " + firstPara.innerText.substring(0, 20));
|
||||
} else {
|
||||
console.log(logTag + ": [PARAGRAPH] No <p> tag found to check.");
|
||||
console.log(logTag + ":[PARAGRAPH] Computed font-family: " + computedPara);
|
||||
}
|
||||
|
||||
if (fontFamily && fontFamily !== "") {
|
||||
var isCheckAvailable = document.fonts && document.fonts.check;
|
||||
|
||||
if (isCheckAvailable) {
|
||||
var loaded = document.fonts.check("12px '" + fontFamily + "'");
|
||||
console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded);
|
||||
} else {
|
||||
console.log(logTag + ": document.fonts API not available.");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
300,
|
||||
300
|
||||
);
|
||||
|
||||
if (window.triggerInitialScrollStateReport) {
|
||||
|
|
|
|||
|
|
@ -396,6 +396,8 @@ class FolderSyncWorker(
|
|||
return when {
|
||||
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
||||
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
|
||||
mimeType == "application/vnd.oasis.opendocument.text" || lowerName.endsWith(".odt") -> FileType.ODT
|
||||
mimeType == "application/x-vnd.oasis.opendocument.text-flat-xml" || lowerName.endsWith(".fodt") -> FileType.FODT
|
||||
mimeType == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || lowerName.endsWith(".docx") -> FileType.DOCX
|
||||
lowerName.endsWith(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
|
||||
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class MetadataExtractionWorker(
|
|||
private val epubParser = EpubParser(appContext)
|
||||
private val mobiParser = MobiParser(appContext)
|
||||
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
||||
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "MetadataExtractionWorker"
|
||||
|
|
@ -109,6 +110,18 @@ class MetadataExtractionWorker(
|
|||
}
|
||||
title = item.displayName
|
||||
}
|
||||
FileType.ODT, FileType.FODT -> {
|
||||
val book = odtParser.createOdtBook(
|
||||
inputStream = inputStream,
|
||||
bookId = item.bookId,
|
||||
originalBookNameHint = item.displayName,
|
||||
isFlat = type == FileType.FODT,
|
||||
parseContent = false
|
||||
)
|
||||
title = book.title.takeIf { it.isNotBlank() && it != "content" }
|
||||
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
|
||||
}
|
||||
else -> {
|
||||
title = item.displayName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,17 +24,12 @@ import android.annotation.SuppressLint
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Build
|
||||
import timber.log.Timber
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -68,6 +63,8 @@ import androidx.compose.foundation.layout.statusBars
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -85,10 +82,14 @@ import androidx.compose.material.icons.filled.Menu
|
|||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
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
|
||||
|
|
@ -99,11 +100,17 @@ import androidx.compose.material3.Icon
|
|||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -130,11 +137,13 @@ import com.aryan.reader.SearchState
|
|||
import com.aryan.reader.SearchTopBar
|
||||
import com.aryan.reader.TooltipIconButton
|
||||
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.TtsPlaybackManager.TtsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class ReaderTool(val title: String, val category: String) {
|
||||
|
|
@ -504,6 +513,8 @@ fun EpubReaderBottomBar(
|
|||
isTtsSessionActive: Boolean,
|
||||
ttsState: TtsState,
|
||||
isProUser: Boolean,
|
||||
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||
onOpenTtsControls: () -> Unit,
|
||||
onOpenSlider: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onToggleFormat: () -> Unit,
|
||||
|
|
@ -658,6 +669,19 @@ fun EpubReaderBottomBar(
|
|||
) 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1421,4 +1445,198 @@ fun CustomizeToolsSheet(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TtsControlsSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onOpenDeviceVoiceSettings: () -> Unit,
|
||||
ttsController: com.aryan.reader.tts.TtsController
|
||||
) {
|
||||
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 saveAndSlice = {
|
||||
saveTtsSpeechRate(context, rate)
|
||||
saveTtsPitch(context, pitch)
|
||||
ttsController.sliceAndRetainPosition()
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
contentWindowInsets = { WindowInsets.navigationBars }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 24.dp)) {
|
||||
Text("Voice Adjustments", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Rate Slider
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Speed (${"%.1f".format(rate)}x)", 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("Pitch (${"%.1f".format(pitch)}x)", 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("This is how your current voice settings sound.", TextToSpeech.QUEUE_FLUSH, null, null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isTtsReady,
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.GraphicEq, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Play Sample")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Central Play/Pause Control for the Book
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
tts?.stop()
|
||||
if (ttsState.isPlaying) ttsController.pause() else ttsController.resume()
|
||||
},
|
||||
modifier = Modifier.size(64.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
) {
|
||||
if (ttsState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = if (ttsState.isPlaying) "Pause Book" else "Play Book",
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (ttsState.isPlaying) "Pause Book" else "Resume Book",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onOpenDeviceVoiceSettings()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("System Voice / Engine Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,6 +1042,7 @@ 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) }
|
||||
|
||||
|
|
@ -3722,6 +3723,8 @@ fun EpubReaderHost(
|
|||
ttsState = ttsState,
|
||||
isProUser = isProUser,
|
||||
hiddenTools = hiddenTools,
|
||||
currentTtsMode = currentTtsMode,
|
||||
onOpenTtsControls = { showTtsControlsSheet = true },
|
||||
onOpenSlider = {
|
||||
when (currentRenderMode) {
|
||||
RenderMode.VERTICAL_SCROLL -> {
|
||||
|
|
@ -4176,6 +4179,14 @@ fun EpubReaderHost(
|
|||
)
|
||||
}
|
||||
|
||||
if (showTtsControlsSheet) {
|
||||
TtsControlsSheet(
|
||||
onDismiss = { showTtsControlsSheet = false },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
ttsController = ttsController
|
||||
)
|
||||
}
|
||||
|
||||
if (showCustomizeToolsSheet) {
|
||||
CustomizeToolsSheet(
|
||||
hiddenTools = hiddenTools,
|
||||
|
|
|
|||
|
|
@ -114,8 +114,30 @@ private const val PAGE_INFO_MODE_KEY = "reader_page_info_mode"
|
|||
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
||||
|
||||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
||||
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
|
||||
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
|
||||
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
|
||||
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
|
||||
private const val TTS_PITCH_KEY = "tts_pitch"
|
||||
|
||||
fun saveTtsSpeechRate(context: Context, rate: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putFloat(TTS_SPEECH_RATE_KEY, rate) }
|
||||
}
|
||||
|
||||
fun loadTtsSpeechRate(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getFloat(TTS_SPEECH_RATE_KEY, 1.0f)
|
||||
}
|
||||
|
||||
fun saveTtsPitch(context: Context, pitch: Float) {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit { putFloat(TTS_PITCH_KEY, pitch) }
|
||||
}
|
||||
|
||||
fun loadTtsPitch(context: Context): Float {
|
||||
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getFloat(TTS_PITCH_KEY, 1.0f)
|
||||
}
|
||||
|
||||
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||
ORIGINAL("original", "Original", "Original"),
|
||||
|
|
@ -559,7 +581,7 @@ fun ReaderTextFormatPanel(
|
|||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Size
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
|
||||
Text("Font Size", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentFontSize,
|
||||
onValueChange = onFontSizeChange,
|
||||
|
|
@ -567,31 +589,31 @@ fun ReaderTextFormatPanel(
|
|||
steps = 24,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text("%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
Text(if (currentFontSize in 0.99f..1.01f) "Orig" else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
// Lines
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Lines", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
|
||||
Text("Line Height", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentLineHeight,
|
||||
onValueChange = onLineHeightChange,
|
||||
valueRange = 1.0f..2.5f,
|
||||
steps = 14,
|
||||
valueRange = 1.0f..3.0f,
|
||||
steps = 19,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text("%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
Text(if (currentLineHeight <= 1.01f) "Orig" else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
// Paragraph Gap
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Gap", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(60.dp))
|
||||
Text("Paragraph Gap", style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
|
||||
Slider(
|
||||
value = currentParagraphGap,
|
||||
onValueChange = onParagraphGapChange,
|
||||
valueRange = 0.0f..3.0f,
|
||||
steps = 12,
|
||||
steps = 29,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text("%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
Text(if (currentParagraphGap in 0.99f..1.01f) "Orig" else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
|
|
|||
|
|
@ -1145,6 +1145,26 @@ private fun getHighlightOffsetsInBlock(
|
|||
return null
|
||||
}
|
||||
|
||||
private fun List<ContentBlock>.extractTextBlocks(): List<TextContentBlock> {
|
||||
val result = mutableListOf<TextContentBlock>()
|
||||
for (block in this) {
|
||||
when (block) {
|
||||
is WrappingContentBlock -> result.addAll(block.paragraphsToWrap)
|
||||
is FlexContainerBlock -> result.addAll(block.children.extractTextBlocks())
|
||||
is TableBlock -> {
|
||||
block.rows.forEach { row ->
|
||||
row.forEach { cell ->
|
||||
result.addAll(cell.content.extractTextBlocks())
|
||||
}
|
||||
}
|
||||
}
|
||||
is TextContentBlock -> result.add(block)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextWithEmphasis(
|
||||
text: AnnotatedString,
|
||||
|
|
@ -1577,13 +1597,22 @@ internal fun PaginatedReaderContent(
|
|||
}
|
||||
|
||||
val textBlocksOnPage =
|
||||
pageContent?.content?.filterIsInstance<TextContentBlock>()
|
||||
pageContent?.content?.extractTextBlocks()
|
||||
?.filter { it.cfi != null } ?: emptyList()
|
||||
val lastTextBlock = textBlocksOnPage.lastOrNull()
|
||||
val lastBlockAbs = lastTextBlock?.let {
|
||||
when (it) {
|
||||
is ParagraphBlock -> it.startCharOffsetInSource
|
||||
is HeaderBlock -> it.startCharOffsetInSource
|
||||
is QuoteBlock -> it.startCharOffsetInSource
|
||||
is ListItemBlock -> it.startCharOffsetInSource
|
||||
}
|
||||
}
|
||||
|
||||
// Strict Trigger Check - Custom Selection
|
||||
LaunchedEffect(activeSelection, lastTextBlock, isDraggingHandle) {
|
||||
if (isDraggingHandle && activeSelection != null && lastTextBlock != null && activeSelection!!.endBlockIndex == lastTextBlock.blockIndex) {
|
||||
if (isDraggingHandle && activeSelection != null && lastTextBlock != null &&
|
||||
activeSelection!!.endBlockIndex == lastTextBlock.blockIndex &&
|
||||
activeSelection!!.endBlockCharOffset == lastBlockAbs) {
|
||||
if (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) {
|
||||
if (crossPageTriggerInfo?.first != pageIndex) {
|
||||
Timber.tag("TextSelectionDiag")
|
||||
|
|
@ -1607,7 +1636,7 @@ internal fun PaginatedReaderContent(
|
|||
val content = pageContent ?: return@LaunchedEffect
|
||||
|
||||
val firstTextBlock =
|
||||
content.content.filterIsInstance<TextContentBlock>()
|
||||
content.content.extractTextBlocks()
|
||||
.firstOrNull { it.cfi != null } ?: run {
|
||||
pendingCrossPageSelection = null
|
||||
return@LaunchedEffect
|
||||
|
|
|
|||
|
|
@ -120,9 +120,12 @@ import com.aryan.reader.pdf.data.VirtualPage
|
|||
import com.aryan.reader.pdf.ocr.OcrElement
|
||||
import com.aryan.reader.pdf.ocr.OcrResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -169,7 +172,7 @@ data class EmbeddedAnnotation(
|
|||
|
||||
data class PdfPoint(val x: Float, val y: Float, val timestamp: Long = 0L)
|
||||
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int)
|
||||
data class PdfTile(val bitmap: Bitmap, val renderRect: Rect, val tileId: Int, val renderScale: Float = 1f)
|
||||
|
||||
enum class LinkSource {
|
||||
ANNOTATION, TEXT_CONTENT
|
||||
|
|
@ -371,6 +374,7 @@ data class PageSelectionData(
|
|||
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
|
||||
)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Suppress("unused")
|
||||
@Composable
|
||||
internal fun PdfPageComposable(
|
||||
|
|
@ -1104,12 +1108,15 @@ internal fun PdfPageComposable(
|
|||
try {
|
||||
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
|
||||
|
||||
snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect ->
|
||||
snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect ->
|
||||
|
||||
delay(150)
|
||||
|
||||
val tileCalcStart = System.nanoTime()
|
||||
if (!isActive) return@collect
|
||||
if (!isActive) return@collectLatest
|
||||
|
||||
if (isScrolling && effectiveScale > 1f) {
|
||||
return@collect
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
val pxTl: Float
|
||||
|
|
@ -1131,20 +1138,16 @@ internal fun PdfPageComposable(
|
|||
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||
}
|
||||
}
|
||||
return@collect
|
||||
return@collectLatest
|
||||
}
|
||||
} else {
|
||||
val pivotX = screenWidth / 2f
|
||||
val pivotY = screenHeight / 2f
|
||||
|
||||
pxTl =
|
||||
(((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyTl =
|
||||
(((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
pxBr =
|
||||
(((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyBr =
|
||||
(((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||
pyBr = (((screenHeight - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||
}
|
||||
|
||||
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
|
||||
|
|
@ -1154,13 +1157,11 @@ internal fun PdfPageComposable(
|
|||
val requiredTileIds = mutableSetOf<Int>()
|
||||
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
|
||||
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
|
||||
val endCol =
|
||||
((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
|
||||
val endCol = ((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
|
||||
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
|
||||
val endRow =
|
||||
((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
|
||||
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
|
||||
)
|
||||
val endRow = ((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
|
||||
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
|
||||
)
|
||||
|
||||
for (row in startRow until endRow) {
|
||||
for (col in startCol until endCol) {
|
||||
|
|
@ -1170,6 +1171,9 @@ internal fun PdfPageComposable(
|
|||
|
||||
val currentTileIds = tiles.map { it.tileId }.toSet()
|
||||
|
||||
val scaleTolerance = 0.05f
|
||||
val validCurrentTileIds = tiles.filter { abs(it.renderScale - effectiveScale) <= scaleTolerance }.map { it.tileId }.toSet()
|
||||
|
||||
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
|
||||
if (duration > 2f) {
|
||||
Timber.tag("PdfPerformance").d(
|
||||
|
|
@ -1177,9 +1181,9 @@ internal fun PdfPageComposable(
|
|||
)
|
||||
}
|
||||
|
||||
if (requiredTileIds != currentTileIds) {
|
||||
if (requiredTileIds != validCurrentTileIds) {
|
||||
|
||||
val tilesToRenderIds = requiredTileIds - currentTileIds
|
||||
val tilesToRenderIds = requiredTileIds - validCurrentTileIds
|
||||
val tilesToRecycleIds = currentTileIds - requiredTileIds
|
||||
|
||||
if (tilesToRecycleIds.isNotEmpty()) {
|
||||
|
|
@ -1205,15 +1209,12 @@ internal fun PdfPageComposable(
|
|||
(col + 1) * tileSizePx,
|
||||
(row + 1) * tileSizePx
|
||||
)
|
||||
val tileRenderSize =
|
||||
(tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
|
||||
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
|
||||
|
||||
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
|
||||
|
||||
val fullPageRenderWidth =
|
||||
(actualBitmapWidthPx * effectiveScale).toInt()
|
||||
val fullPageRenderHeight =
|
||||
(actualBitmapHeightPx * effectiveScale).toInt()
|
||||
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
|
||||
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
|
||||
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
|
||||
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
|
||||
|
||||
|
|
@ -1226,12 +1227,19 @@ internal fun PdfPageComposable(
|
|||
renderAnnot = true
|
||||
)
|
||||
|
||||
val newTile = PdfTile(tileBitmap, tileRect, tileId)
|
||||
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
|
||||
var handedOver = false
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
tiles = tiles + newTile
|
||||
val oldTile = tiles.find { it.tileId == tileId }
|
||||
tiles = tiles.filter { it.tileId != tileId } + newTile
|
||||
handedOver = true
|
||||
|
||||
oldTile?.let {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
PdfBitmapPool.recycle(it.bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!handedOver) {
|
||||
|
|
@ -2746,6 +2754,7 @@ internal fun PdfPageComposable(
|
|||
} else if (mode == 2 && pointerCount > 1) {
|
||||
val oldScale = scale
|
||||
val newScale = (scale * zoomChange).coerceIn(1f, 4f)
|
||||
Timber.tag("PdfZoomIssue").v("Gesture Scaling: old=$oldScale, new=$newScale, zoomChange=$zoomChange")
|
||||
|
||||
val previousCentroid = event.calculateCentroid(useCurrent = false)
|
||||
if (previousCentroid != Offset.Unspecified) {
|
||||
|
|
@ -3328,8 +3337,12 @@ internal fun PdfPageComposable(
|
|||
currentPageRotation = 0
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
var baseW = scaledWidth
|
||||
var baseH = scaledHeight
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
|
|
@ -3394,8 +3407,12 @@ internal fun PdfPageComposable(
|
|||
}
|
||||
|
||||
val MAX_BASE_DIMEN = 3000
|
||||
var baseW = scaledWidth
|
||||
var baseH = scaledHeight
|
||||
|
||||
val baseRenderScale = 1.5f
|
||||
|
||||
var baseW = (scaledWidth * baseRenderScale).toInt()
|
||||
var baseH = (scaledHeight * baseRenderScale).toInt()
|
||||
|
||||
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import android.content.Context
|
|||
import android.content.pm.PackageManager
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.RectF
|
||||
import android.net.Uri
|
||||
|
|
@ -78,6 +80,7 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
|||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -284,6 +287,7 @@ import com.aryan.reader.countWords
|
|||
import com.aryan.reader.epubreader.AutoScrollControls
|
||||
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
||||
import com.aryan.reader.epubreader.ExternalDictionaryHelper
|
||||
import com.aryan.reader.epubreader.TtsControlsSheet
|
||||
import com.aryan.reader.fetchAiDefinition
|
||||
import com.aryan.reader.loadCustomThemes
|
||||
import com.aryan.reader.paginatedreader.TtsChunk
|
||||
|
|
@ -1322,6 +1326,7 @@ fun PdfViewerScreen(
|
|||
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
|
||||
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
|
||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
||||
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
|
||||
|
||||
DisposableEffect(isKeepScreenOn) {
|
||||
|
|
@ -6322,12 +6327,15 @@ fun PdfViewerScreen(
|
|||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
val bottomBarScrollState = rememberScrollState()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = 8.dp)
|
||||
.horizontalScroll(bottomBarScrollState),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Slider Navigation Trigger
|
||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||
|
|
@ -6498,58 +6506,73 @@ fun PdfViewerScreen(
|
|||
|
||||
// TTS
|
||||
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
||||
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 = {
|
||||
if (isTtsSessionActive) {
|
||||
Timber.d("TTS button clicked: Stopping TTS")
|
||||
ttsController.stop()
|
||||
} else {
|
||||
startTtsWithPermissionCheck(null, null)
|
||||
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 = {
|
||||
if (isTtsSessionActive) {
|
||||
Timber.d("TTS button clicked: Stopping TTS")
|
||||
ttsController.stop()
|
||||
} else {
|
||||
startTtsWithPermissionCheck(null, null)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close)
|
||||
else painterResource(
|
||||
id = R.drawable.text_to_speech
|
||||
),
|
||||
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close)
|
||||
else painterResource(
|
||||
id = R.drawable.text_to_speech
|
||||
),
|
||||
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TTS Pause/Resume Button
|
||||
if (isTtsSessionActive) {
|
||||
TooltipIconButton(
|
||||
text = if (ttsState.isPlaying)
|
||||
stringResource(R.string.tooltip_tts_pause)
|
||||
else
|
||||
stringResource(R.string.tooltip_tts_resume),
|
||||
description = if (ttsState.isPlaying)
|
||||
stringResource(R.string.tooltip_tts_pause_desc)
|
||||
else
|
||||
stringResource(R.string.tooltip_tts_resume_desc),
|
||||
onClick = {
|
||||
if (ttsState.isPlaying) {
|
||||
ttsController.pause()
|
||||
} else {
|
||||
ttsController.resume()
|
||||
if (isTtsSessionActive) {
|
||||
TooltipIconButton(
|
||||
text = if (ttsState.isPlaying)
|
||||
stringResource(R.string.tooltip_tts_pause)
|
||||
else
|
||||
stringResource(R.string.tooltip_tts_resume),
|
||||
description = if (ttsState.isPlaying)
|
||||
stringResource(R.string.tooltip_tts_pause_desc)
|
||||
else
|
||||
stringResource(R.string.tooltip_tts_resume_desc),
|
||||
onClick = {
|
||||
if (ttsState.isPlaying) {
|
||||
ttsController.pause()
|
||||
} else {
|
||||
ttsController.resume()
|
||||
}
|
||||
}, enabled = !ttsState.isLoading
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (ttsState.isPlaying) R.drawable.pause
|
||||
else R.drawable.play
|
||||
), contentDescription = if (ttsState.isPlaying) "Pause TTS"
|
||||
else "Resume TTS"
|
||||
)
|
||||
}
|
||||
|
||||
// Tune button for BASE mode
|
||||
if (currentTtsMode == TtsPlaybackManager.TtsMode.BASE) {
|
||||
TooltipIconButton(
|
||||
text = "Voice Adjustments",
|
||||
description = "Adjust voice speed and pitch",
|
||||
onClick = { showTtsControlsSheet = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tune,
|
||||
contentDescription = "Voice Adjustments"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, enabled = !ttsState.isLoading
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (ttsState.isPlaying) R.drawable.pause
|
||||
else R.drawable.play
|
||||
), contentDescription = if (ttsState.isPlaying) "Pause TTS"
|
||||
else "Resume TTS"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
// Error Message Area
|
||||
|
|
@ -7458,6 +7481,14 @@ fun PdfViewerScreen(
|
|||
)
|
||||
}
|
||||
|
||||
if (showTtsControlsSheet) {
|
||||
TtsControlsSheet(
|
||||
onDismiss = { showTtsControlsSheet = false },
|
||||
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||
ttsController = ttsController
|
||||
)
|
||||
}
|
||||
|
||||
if (showDictionarySettingsSheet) {
|
||||
DictionarySettingsDialog(
|
||||
isVisible = true,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import android.content.Context
|
|||
import android.os.Bundle
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import com.aryan.reader.epubreader.loadTtsPitch
|
||||
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||
import com.aryan.reader.loadNativeVoice
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
|
@ -198,6 +200,9 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
|
||||
applyPreferredVoice()
|
||||
|
||||
tts?.setSpeechRate(loadTtsSpeechRate(context))
|
||||
tts?.setPitch(loadTtsPitch(context))
|
||||
|
||||
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
|
||||
|
||||
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
|
||||
|
|
|
|||
|
|
@ -230,6 +230,16 @@ class TtsController(context: Context) : Player.Listener {
|
|||
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
||||
}
|
||||
|
||||
fun flushPrefetch() {
|
||||
Timber.d("UI sending FLUSH_PREFETCH command.")
|
||||
mediaController?.sendCustomCommand(FLUSH_PREFETCH_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
fun sliceAndRetainPosition() {
|
||||
Timber.d("UI sending SLICE_CURRENT_AND_RELOAD command.")
|
||||
mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
updateStateFromController()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ import kotlinx.coroutines.delay
|
|||
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
|
||||
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
|
||||
val CHANGE_SPEAKER_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER", Bundle.EMPTY)
|
||||
val FLUSH_PREFETCH_COMMAND = SessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH", Bundle.EMPTY)
|
||||
private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY)
|
||||
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
|
||||
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
|
||||
|
||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||
|
|
@ -137,6 +139,8 @@ class TtsPlaybackManager(
|
|||
.add(STOP_TTS_COMMAND)
|
||||
.add(CHANGE_SPEAKER_COMMAND)
|
||||
.add(CHANGE_TTS_MODE_COMMAND)
|
||||
.add(FLUSH_PREFETCH_COMMAND)
|
||||
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
|
||||
.build()
|
||||
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
|
|
@ -203,10 +207,71 @@ class TtsPlaybackManager(
|
|||
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
|
||||
handleChangeTtsMode(newMode)
|
||||
}
|
||||
FLUSH_PREFETCH_COMMAND -> {
|
||||
Timber.d("Flushing prefetched TTS chunks for new parameters.")
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
|
||||
if (currentIdx == C.INDEX_UNSET) return@launch
|
||||
val keysToRemove = audioFiles.keys.filter { it > currentIdx }
|
||||
keysToRemove.forEach { key ->
|
||||
audioFiles.remove(key)?.delete()
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
prefetchNextChunkAudio(currentIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
SLICE_CURRENT_AND_RELOAD_COMMAND -> {
|
||||
handleSliceAndReload()
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
private fun handleSliceAndReload() {
|
||||
val currentIdx = player.currentMediaItemIndex
|
||||
if (currentIdx == C.INDEX_UNSET) return
|
||||
|
||||
val offset = _ttsState.value.currentWordStartOffset
|
||||
val currentChunk = textChunks.getOrNull(currentIdx) ?: return
|
||||
|
||||
preparationJob?.cancel()
|
||||
wordTrackingJob?.cancel()
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
prefetchingJobs.clear()
|
||||
|
||||
preparationJob = scope.launch {
|
||||
clearAudioFiles()
|
||||
|
||||
if (offset == -1) {
|
||||
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val relativeOffset = (offset - currentChunk.startOffsetInSource).coerceIn(0, currentChunk.text.length)
|
||||
|
||||
if (relativeOffset >= currentChunk.text.length) {
|
||||
if (currentIdx + 1 < textChunks.size) {
|
||||
prepareAndPlayFirstChunk(startAtIndex = currentIdx + 1, playWhenReady = false)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val slicedText = currentChunk.text.substring(relativeOffset)
|
||||
val newChunk = currentChunk.copy(text = slicedText, startOffsetInSource = offset)
|
||||
|
||||
val mutableChunks = textChunks.toMutableList()
|
||||
mutableChunks[currentIdx] = newChunk
|
||||
textChunks = mutableChunks.toList()
|
||||
|
||||
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleChangeTtsMode(newMode: TtsMode) {
|
||||
if (currentTtsMode == newMode) return
|
||||
currentTtsMode = newMode
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue