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"
|
applicationId = "com.aryan.reader"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 42
|
versionCode = 43
|
||||||
versionName = "1.0.41"
|
versionName = "1.0.42"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
|
||||||
|
|
@ -481,83 +481,60 @@
|
||||||
var newGap = parseFloat(paragraphGap);
|
var newGap = parseFloat(paragraphGap);
|
||||||
|
|
||||||
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
|
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;
|
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
|
||||||
|
|
||||||
var fontCss = "";
|
var fontCss = "";
|
||||||
var selector = "body";
|
|
||||||
|
|
||||||
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
|
||||||
var fallback = "sans-serif";
|
var fallback = "sans-serif";
|
||||||
|
|
||||||
if (fontFamily === "Merriweather" || fontFamily === "Lora") {
|
if (fontFamily === "Merriweather" || fontFamily === "Lora") {
|
||||||
fallback = "serif";
|
fallback = "serif";
|
||||||
} else if (fontFamily === "Roboto Mono") {
|
} else if (fontFamily === "Roboto Mono") {
|
||||||
fallback = "monospace";
|
fallback = "monospace";
|
||||||
}
|
}
|
||||||
|
fontCss = `
|
||||||
selector = "body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th";
|
body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
|
||||||
fontCss = "font-family: '" + fontFamily + "', " + fallback + " !important;";
|
font-family: '${fontFamily}', ${fallback} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ALIGNMENT LOGIC ---
|
|
||||||
var alignCss = "";
|
var alignCss = "";
|
||||||
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
|
var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6";
|
||||||
|
|
||||||
if (textAlign === "left") {
|
if (textAlign === "left") {
|
||||||
alignCss =
|
alignCss = alignSelector + " { text-align: left !important; }";
|
||||||
` ` +
|
|
||||||
alignSelector +
|
|
||||||
` {
|
|
||||||
text-align: left !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
`;
|
|
||||||
} else if (textAlign === "justify") {
|
} else if (textAlign === "justify") {
|
||||||
alignCss =
|
alignCss = alignSelector + " { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; }";
|
||||||
` ` +
|
|
||||||
alignSelector +
|
|
||||||
` {
|
|
||||||
text-align: justify !important;
|
|
||||||
-webkit-hyphens: auto !important;
|
|
||||||
hyphens: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
`;
|
var gapCss = "";
|
||||||
}
|
if (newGap !== 1.0) {
|
||||||
|
gapCss = `
|
||||||
// --- GAP LOGIC ---
|
|
||||||
var gapCss = `
|
|
||||||
body p, body ul, body ol, body blockquote {
|
body p, body ul, body ol, body blockquote {
|
||||||
margin-top: ` + (0.5 * newGap) + `em !important;
|
margin-top: ${newGap}em !important;
|
||||||
margin-bottom: ` + (0.5 * newGap) + `em !important;
|
margin-bottom: ${newGap}em !important;
|
||||||
}
|
}
|
||||||
body li {
|
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 = "";
|
||||||
selector +
|
if (newFontSize !== 1.0) {
|
||||||
` {
|
sizeCss = `body { font-size: ${newFontSize}em !important; }`;
|
||||||
` +
|
|
||||||
fontCss +
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
` +
|
var lineHeightCss = "";
|
||||||
alignCss +
|
if (newLineHeight !== 1.0) {
|
||||||
gapCss;
|
lineHeightCss = `
|
||||||
|
body, p, div, span, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th {
|
||||||
|
line-height: ${newLineHeight} !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n");
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
function () {
|
function () {
|
||||||
|
|
@ -565,29 +542,20 @@
|
||||||
console.log(logTag + ": [BODY] Computed font-family: " + computedBody);
|
console.log(logTag + ": [BODY] Computed font-family: " + computedBody);
|
||||||
|
|
||||||
var firstPara = document.querySelector("p");
|
var firstPara = document.querySelector("p");
|
||||||
|
|
||||||
if (firstPara) {
|
if (firstPara) {
|
||||||
var computedPara = window.getComputedStyle(firstPara).fontFamily;
|
var computedPara = window.getComputedStyle(firstPara).fontFamily;
|
||||||
var computedAlign = window.getComputedStyle(firstPara).textAlign;
|
|
||||||
console.log(logTag + ":[PARAGRAPH] Computed font-family: " + computedPara);
|
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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fontFamily && fontFamily !== "") {
|
if (fontFamily && fontFamily !== "") {
|
||||||
var isCheckAvailable = document.fonts && document.fonts.check;
|
var isCheckAvailable = document.fonts && document.fonts.check;
|
||||||
|
|
||||||
if (isCheckAvailable) {
|
if (isCheckAvailable) {
|
||||||
var loaded = document.fonts.check("12px '" + fontFamily + "'");
|
var loaded = document.fonts.check("12px '" + fontFamily + "'");
|
||||||
console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded);
|
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) {
|
if (window.triggerInitialScrollStateReport) {
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,8 @@ class FolderSyncWorker(
|
||||||
return when {
|
return when {
|
||||||
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
mimeType == "application/pdf" || lowerName.endsWith(".pdf") -> FileType.PDF
|
||||||
mimeType == "application/epub+zip" || lowerName.endsWith(".epub") -> FileType.EPUB
|
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
|
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(".mobi") || lowerName.endsWith(".azw3") || lowerName.endsWith(".prc") -> FileType.MOBI
|
||||||
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
|
lowerName.endsWith(".fb2") || lowerName.endsWith(".fb2.zip") -> FileType.FB2
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class MetadataExtractionWorker(
|
||||||
private val epubParser = EpubParser(appContext)
|
private val epubParser = EpubParser(appContext)
|
||||||
private val mobiParser = MobiParser(appContext)
|
private val mobiParser = MobiParser(appContext)
|
||||||
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
||||||
|
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "MetadataExtractionWorker"
|
const val WORK_NAME = "MetadataExtractionWorker"
|
||||||
|
|
@ -109,6 +110,18 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
title = item.displayName
|
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 -> {
|
else -> {
|
||||||
title = item.displayName
|
title = item.displayName
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,12 @@ import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import timber.log.Timber
|
import android.speech.tts.TextToSpeech
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
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.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
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.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.Remove
|
||||||
import androidx.compose.material.icons.filled.Search
|
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.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
|
@ -99,11 +100,17 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -130,11 +137,13 @@ import com.aryan.reader.SearchState
|
||||||
import com.aryan.reader.SearchTopBar
|
import com.aryan.reader.SearchTopBar
|
||||||
import com.aryan.reader.TooltipIconButton
|
import com.aryan.reader.TooltipIconButton
|
||||||
import com.aryan.reader.epub.EpubChapter
|
import com.aryan.reader.epub.EpubChapter
|
||||||
|
import com.aryan.reader.loadNativeVoice
|
||||||
import com.aryan.reader.paginatedreader.BookPaginator
|
import com.aryan.reader.paginatedreader.BookPaginator
|
||||||
import com.aryan.reader.paginatedreader.IPaginator
|
import com.aryan.reader.paginatedreader.IPaginator
|
||||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
enum class ReaderTool(val title: String, val category: String) {
|
enum class ReaderTool(val title: String, val category: String) {
|
||||||
|
|
@ -504,6 +513,8 @@ fun EpubReaderBottomBar(
|
||||||
isTtsSessionActive: Boolean,
|
isTtsSessionActive: Boolean,
|
||||||
ttsState: TtsState,
|
ttsState: TtsState,
|
||||||
isProUser: Boolean,
|
isProUser: Boolean,
|
||||||
|
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
|
||||||
|
onOpenTtsControls: () -> Unit,
|
||||||
onOpenSlider: () -> Unit,
|
onOpenSlider: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onToggleFormat: () -> Unit,
|
onToggleFormat: () -> Unit,
|
||||||
|
|
@ -658,6 +669,19 @@ fun EpubReaderBottomBar(
|
||||||
) else stringResource(R.string.content_desc_resume_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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1422,3 +1446,197 @@ 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 showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
||||||
var showThemePanel by remember { mutableStateOf(false) }
|
var showThemePanel by remember { mutableStateOf(false) }
|
||||||
var showPaletteManager by remember { mutableStateOf(false) }
|
var showPaletteManager by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -3722,6 +3723,8 @@ fun EpubReaderHost(
|
||||||
ttsState = ttsState,
|
ttsState = ttsState,
|
||||||
isProUser = isProUser,
|
isProUser = isProUser,
|
||||||
hiddenTools = hiddenTools,
|
hiddenTools = hiddenTools,
|
||||||
|
currentTtsMode = currentTtsMode,
|
||||||
|
onOpenTtsControls = { showTtsControlsSheet = true },
|
||||||
onOpenSlider = {
|
onOpenSlider = {
|
||||||
when (currentRenderMode) {
|
when (currentRenderMode) {
|
||||||
RenderMode.VERTICAL_SCROLL -> {
|
RenderMode.VERTICAL_SCROLL -> {
|
||||||
|
|
@ -4176,6 +4179,14 @@ fun EpubReaderHost(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTtsControlsSheet) {
|
||||||
|
TtsControlsSheet(
|
||||||
|
onDismiss = { showTtsControlsSheet = false },
|
||||||
|
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||||
|
ttsController = ttsController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showCustomizeToolsSheet) {
|
if (showCustomizeToolsSheet) {
|
||||||
CustomizeToolsSheet(
|
CustomizeToolsSheet(
|
||||||
hiddenTools = hiddenTools,
|
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"
|
private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
|
||||||
|
|
||||||
const val DEFAULT_FONT_SIZE_VAL = 1.0f
|
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
|
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) {
|
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
|
||||||
ORIGINAL("original", "Original", "Original"),
|
ORIGINAL("original", "Original", "Original"),
|
||||||
|
|
@ -559,7 +581,7 @@ fun ReaderTextFormatPanel(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
// Size
|
// Size
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
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(
|
Slider(
|
||||||
value = currentFontSize,
|
value = currentFontSize,
|
||||||
onValueChange = onFontSizeChange,
|
onValueChange = onFontSizeChange,
|
||||||
|
|
@ -567,31 +589,31 @@ fun ReaderTextFormatPanel(
|
||||||
steps = 24,
|
steps = 24,
|
||||||
modifier = Modifier.weight(1f)
|
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
|
// Lines
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
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(
|
Slider(
|
||||||
value = currentLineHeight,
|
value = currentLineHeight,
|
||||||
onValueChange = onLineHeightChange,
|
onValueChange = onLineHeightChange,
|
||||||
valueRange = 1.0f..2.5f,
|
valueRange = 1.0f..3.0f,
|
||||||
steps = 14,
|
steps = 19,
|
||||||
modifier = Modifier.weight(1f)
|
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
|
// Paragraph Gap
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
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(
|
Slider(
|
||||||
value = currentParagraphGap,
|
value = currentParagraphGap,
|
||||||
onValueChange = onParagraphGapChange,
|
onValueChange = onParagraphGapChange,
|
||||||
valueRange = 0.0f..3.0f,
|
valueRange = 0.0f..3.0f,
|
||||||
steps = 12,
|
steps = 29,
|
||||||
modifier = Modifier.weight(1f)
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -1145,6 +1145,26 @@ private fun getHighlightOffsetsInBlock(
|
||||||
return null
|
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
|
@Composable
|
||||||
private fun TextWithEmphasis(
|
private fun TextWithEmphasis(
|
||||||
text: AnnotatedString,
|
text: AnnotatedString,
|
||||||
|
|
@ -1577,13 +1597,22 @@ internal fun PaginatedReaderContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
val textBlocksOnPage =
|
val textBlocksOnPage =
|
||||||
pageContent?.content?.filterIsInstance<TextContentBlock>()
|
pageContent?.content?.extractTextBlocks()
|
||||||
?.filter { it.cfi != null } ?: emptyList()
|
?.filter { it.cfi != null } ?: emptyList()
|
||||||
val lastTextBlock = textBlocksOnPage.lastOrNull()
|
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) {
|
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 (activeSelection!!.endOffset >= lastTextBlock.content.text.length - 3) {
|
||||||
if (crossPageTriggerInfo?.first != pageIndex) {
|
if (crossPageTriggerInfo?.first != pageIndex) {
|
||||||
Timber.tag("TextSelectionDiag")
|
Timber.tag("TextSelectionDiag")
|
||||||
|
|
@ -1607,7 +1636,7 @@ internal fun PaginatedReaderContent(
|
||||||
val content = pageContent ?: return@LaunchedEffect
|
val content = pageContent ?: return@LaunchedEffect
|
||||||
|
|
||||||
val firstTextBlock =
|
val firstTextBlock =
|
||||||
content.content.filterIsInstance<TextContentBlock>()
|
content.content.extractTextBlocks()
|
||||||
.firstOrNull { it.cfi != null } ?: run {
|
.firstOrNull { it.cfi != null } ?: run {
|
||||||
pendingCrossPageSelection = null
|
pendingCrossPageSelection = null
|
||||||
return@LaunchedEffect
|
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.OcrElement
|
||||||
import com.aryan.reader.pdf.ocr.OcrResult
|
import com.aryan.reader.pdf.ocr.OcrResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.conflate
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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 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 {
|
enum class LinkSource {
|
||||||
ANNOTATION, TEXT_CONTENT
|
ANNOTATION, TEXT_CONTENT
|
||||||
|
|
@ -371,6 +374,7 @@ data class PageSelectionData(
|
||||||
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
|
val customHighlightColors: StableHolder<Map<PdfHighlightColor, Color>>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PdfPageComposable(
|
internal fun PdfPageComposable(
|
||||||
|
|
@ -1104,12 +1108,15 @@ internal fun PdfPageComposable(
|
||||||
try {
|
try {
|
||||||
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
|
page = withContext(Dispatchers.IO) { pdfDocumentItem.openPage(pdfPageIndex) }
|
||||||
|
|
||||||
snapshotFlow { visibleScreenRect() }.conflate().collect { currentVisibleRect ->
|
snapshotFlow { visibleScreenRect() }.conflate().collectLatest { currentVisibleRect ->
|
||||||
|
|
||||||
|
delay(150)
|
||||||
|
|
||||||
val tileCalcStart = System.nanoTime()
|
val tileCalcStart = System.nanoTime()
|
||||||
if (!isActive) return@collect
|
if (!isActive) return@collectLatest
|
||||||
|
|
||||||
if (isScrolling && effectiveScale > 1f) {
|
if (isScrolling && effectiveScale > 1f) {
|
||||||
return@collect
|
return@collectLatest
|
||||||
}
|
}
|
||||||
|
|
||||||
val pxTl: Float
|
val pxTl: Float
|
||||||
|
|
@ -1131,20 +1138,16 @@ internal fun PdfPageComposable(
|
||||||
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
oldTiles.forEach { PdfBitmapPool.recycle(it.bitmap) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return@collect
|
return@collectLatest
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val pivotX = screenWidth / 2f
|
val pivotX = screenWidth / 2f
|
||||||
val pivotY = screenHeight / 2f
|
val pivotY = screenHeight / 2f
|
||||||
|
|
||||||
pxTl =
|
pxTl = (((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||||
(((0 - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
pyTl = (((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
||||||
pyTl =
|
pxBr = (((screenWidth - effectiveOffset.x) - pivotX) / effectiveScale + pivotX) - centeringOffsetX
|
||||||
(((0 - effectiveOffset.y) - pivotY) / effectiveScale + pivotY) - centeringOffsetY
|
pyBr = (((screenHeight - 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())
|
val visibleBitmapRect = Rect(pxTl.toInt(), pyTl.toInt(), pxBr.toInt(), pyBr.toInt())
|
||||||
|
|
@ -1154,11 +1157,9 @@ internal fun PdfPageComposable(
|
||||||
val requiredTileIds = mutableSetOf<Int>()
|
val requiredTileIds = mutableSetOf<Int>()
|
||||||
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
|
val cols = (actualBitmapWidthPx + tileSizePx - 1) / tileSizePx
|
||||||
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
|
val startCol = (visibleBitmapRect.left / tileSizePx).coerceAtLeast(0)
|
||||||
val endCol =
|
val endCol = ((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
|
||||||
((visibleBitmapRect.right + tileSizePx - 1) / tileSizePx).coerceAtMost(cols)
|
|
||||||
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
|
val startRow = (visibleBitmapRect.top / tileSizePx).coerceAtLeast(0)
|
||||||
val endRow =
|
val endRow = ((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
|
||||||
((visibleBitmapRect.bottom + tileSizePx - 1) / tileSizePx).coerceAtMost(
|
|
||||||
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
|
(actualBitmapHeightPx + tileSizePx - 1) / tileSizePx
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1170,6 +1171,9 @@ internal fun PdfPageComposable(
|
||||||
|
|
||||||
val currentTileIds = tiles.map { it.tileId }.toSet()
|
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
|
val duration = (System.nanoTime() - tileCalcStart) / 1_000_000f
|
||||||
if (duration > 2f) {
|
if (duration > 2f) {
|
||||||
Timber.tag("PdfPerformance").d(
|
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
|
val tilesToRecycleIds = currentTileIds - requiredTileIds
|
||||||
|
|
||||||
if (tilesToRecycleIds.isNotEmpty()) {
|
if (tilesToRecycleIds.isNotEmpty()) {
|
||||||
|
|
@ -1205,15 +1209,12 @@ internal fun PdfPageComposable(
|
||||||
(col + 1) * tileSizePx,
|
(col + 1) * tileSizePx,
|
||||||
(row + 1) * tileSizePx
|
(row + 1) * tileSizePx
|
||||||
)
|
)
|
||||||
val tileRenderSize =
|
val tileRenderSize = (tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
|
||||||
(tileSizePx * effectiveScale).toInt().coerceAtLeast(1)
|
|
||||||
|
|
||||||
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
|
val tileBitmap = PdfBitmapPool.get(tileRenderSize)
|
||||||
|
|
||||||
val fullPageRenderWidth =
|
val fullPageRenderWidth = (actualBitmapWidthPx * effectiveScale).toInt()
|
||||||
(actualBitmapWidthPx * effectiveScale).toInt()
|
val fullPageRenderHeight = (actualBitmapHeightPx * effectiveScale).toInt()
|
||||||
val fullPageRenderHeight =
|
|
||||||
(actualBitmapHeightPx * effectiveScale).toInt()
|
|
||||||
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
|
val tileRenderX = (col * tileSizePx * effectiveScale).toInt()
|
||||||
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
|
val tileRenderY = (row * tileSizePx * effectiveScale).toInt()
|
||||||
|
|
||||||
|
|
@ -1226,12 +1227,19 @@ internal fun PdfPageComposable(
|
||||||
renderAnnot = true
|
renderAnnot = true
|
||||||
)
|
)
|
||||||
|
|
||||||
val newTile = PdfTile(tileBitmap, tileRect, tileId)
|
val newTile = PdfTile(tileBitmap, tileRect, tileId, effectiveScale)
|
||||||
var handedOver = false
|
var handedOver = false
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
tiles = tiles + newTile
|
val oldTile = tiles.find { it.tileId == tileId }
|
||||||
|
tiles = tiles.filter { it.tileId != tileId } + newTile
|
||||||
handedOver = true
|
handedOver = true
|
||||||
|
|
||||||
|
oldTile?.let {
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
PdfBitmapPool.recycle(it.bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!handedOver) {
|
if (!handedOver) {
|
||||||
|
|
@ -2746,6 +2754,7 @@ internal fun PdfPageComposable(
|
||||||
} else if (mode == 2 && pointerCount > 1) {
|
} else if (mode == 2 && pointerCount > 1) {
|
||||||
val oldScale = scale
|
val oldScale = scale
|
||||||
val newScale = (scale * zoomChange).coerceIn(1f, 4f)
|
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)
|
val previousCentroid = event.calculateCentroid(useCurrent = false)
|
||||||
if (previousCentroid != Offset.Unspecified) {
|
if (previousCentroid != Offset.Unspecified) {
|
||||||
|
|
@ -3328,8 +3337,12 @@ internal fun PdfPageComposable(
|
||||||
currentPageRotation = 0
|
currentPageRotation = 0
|
||||||
|
|
||||||
val MAX_BASE_DIMEN = 3000
|
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) {
|
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||||
|
|
@ -3394,8 +3407,12 @@ internal fun PdfPageComposable(
|
||||||
}
|
}
|
||||||
|
|
||||||
val MAX_BASE_DIMEN = 3000
|
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) {
|
if (baseW > MAX_BASE_DIMEN || baseH > MAX_BASE_DIMEN) {
|
||||||
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
val downScale = MAX_BASE_DIMEN.toFloat() / maxOf(baseW, baseH)
|
||||||
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
baseW = (baseW * downScale).toInt().coerceAtLeast(1)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.Bitmap
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.net.Uri
|
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.draggable
|
||||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.AutoScrollControls
|
||||||
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
import com.aryan.reader.epubreader.DictionarySettingsDialog
|
||||||
import com.aryan.reader.epubreader.ExternalDictionaryHelper
|
import com.aryan.reader.epubreader.ExternalDictionaryHelper
|
||||||
|
import com.aryan.reader.epubreader.TtsControlsSheet
|
||||||
import com.aryan.reader.fetchAiDefinition
|
import com.aryan.reader.fetchAiDefinition
|
||||||
import com.aryan.reader.loadCustomThemes
|
import com.aryan.reader.loadCustomThemes
|
||||||
import com.aryan.reader.paginatedreader.TtsChunk
|
import com.aryan.reader.paginatedreader.TtsChunk
|
||||||
|
|
@ -1322,6 +1326,7 @@ fun PdfViewerScreen(
|
||||||
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
|
var isStylusOnlyMode by remember { mutableStateOf(loadStylusOnlyMode(context)) }
|
||||||
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
|
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
|
||||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showTtsControlsSheet by remember { mutableStateOf(false) }
|
||||||
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
|
var isKeepScreenOn by remember { mutableStateOf(loadKeepScreenOn(context)) }
|
||||||
|
|
||||||
DisposableEffect(isKeepScreenOn) {
|
DisposableEffect(isKeepScreenOn) {
|
||||||
|
|
@ -6322,12 +6327,15 @@ fun PdfViewerScreen(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 4.dp
|
tonalElevation = 4.dp
|
||||||
) {
|
) {
|
||||||
|
val bottomBarScrollState = rememberScrollState()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 8.dp)
|
||||||
|
.horizontalScroll(bottomBarScrollState),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// Slider Navigation Trigger
|
// Slider Navigation Trigger
|
||||||
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
|
||||||
|
|
@ -6498,6 +6506,8 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
// TTS
|
// TTS
|
||||||
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
if (!hiddenTools.contains(PdfReaderTool.TTS_CONTROLS.name)) {
|
||||||
|
Box {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
|
text = if (isTtsSessionActive) stringResource(R.string.tooltip_tts_stop)
|
||||||
else stringResource(R.string.tooltip_tts_start),
|
else stringResource(R.string.tooltip_tts_start),
|
||||||
|
|
@ -6519,9 +6529,7 @@ fun PdfViewerScreen(
|
||||||
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
|
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TTS Pause/Resume Button
|
|
||||||
if (isTtsSessionActive) {
|
if (isTtsSessionActive) {
|
||||||
TooltipIconButton(
|
TooltipIconButton(
|
||||||
text = if (ttsState.isPlaying)
|
text = if (ttsState.isPlaying)
|
||||||
|
|
@ -6548,8 +6556,23 @@ fun PdfViewerScreen(
|
||||||
else "Resume TTS"
|
else "Resume TTS"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Spacer(Modifier.size(48.dp))
|
// 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error Message Area
|
// Error Message Area
|
||||||
|
|
@ -7458,6 +7481,14 @@ fun PdfViewerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTtsControlsSheet) {
|
||||||
|
TtsControlsSheet(
|
||||||
|
onDismiss = { showTtsControlsSheet = false },
|
||||||
|
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
|
||||||
|
ttsController = ttsController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showDictionarySettingsSheet) {
|
if (showDictionarySettingsSheet) {
|
||||||
DictionarySettingsDialog(
|
DictionarySettingsDialog(
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import com.aryan.reader.epubreader.loadTtsPitch
|
||||||
|
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||||
import com.aryan.reader.loadNativeVoice
|
import com.aryan.reader.loadNativeVoice
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
|
@ -198,6 +200,9 @@ class BaseTtsSynthesizer(private val context: Context) {
|
||||||
|
|
||||||
applyPreferredVoice()
|
applyPreferredVoice()
|
||||||
|
|
||||||
|
tts?.setSpeechRate(loadTtsSpeechRate(context))
|
||||||
|
tts?.setPitch(loadTtsPitch(context))
|
||||||
|
|
||||||
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
|
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
|
||||||
|
|
||||||
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
|
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,16 @@ class TtsController(context: Context) : Player.Listener {
|
||||||
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
|
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) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
updateStateFromController()
|
updateStateFromController()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,10 @@ import kotlinx.coroutines.delay
|
||||||
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
|
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 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 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)
|
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 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_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||||
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||||
|
|
@ -137,6 +139,8 @@ class TtsPlaybackManager(
|
||||||
.add(STOP_TTS_COMMAND)
|
.add(STOP_TTS_COMMAND)
|
||||||
.add(CHANGE_SPEAKER_COMMAND)
|
.add(CHANGE_SPEAKER_COMMAND)
|
||||||
.add(CHANGE_TTS_MODE_COMMAND)
|
.add(CHANGE_TTS_MODE_COMMAND)
|
||||||
|
.add(FLUSH_PREFETCH_COMMAND)
|
||||||
|
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
|
||||||
.build()
|
.build()
|
||||||
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
|
||||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||||
|
|
@ -203,10 +207,71 @@ class TtsPlaybackManager(
|
||||||
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
|
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
|
||||||
handleChangeTtsMode(newMode)
|
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))
|
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) {
|
private fun handleChangeTtsMode(newMode: TtsMode) {
|
||||||
if (currentTtsMode == newMode) return
|
if (currentTtsMode == newMode) return
|
||||||
currentTtsMode = newMode
|
currentTtsMode = newMode
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue