* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -70,58 +70,16 @@ import androidx.core.content.edit
import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import com.aryan.reader.shared.EpubAnnotationSerializer
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
data class Bookmark(
val cfi: String,
val chapterTitle: String,
val label: String? = null,
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red"),
PURPLE("purple", Color(0xFF7B1FA2), "user-highlight-purple"),
ORANGE("orange", Color(0xFFF57C00), "user-highlight-orange"),
CYAN("cyan", Color(0xFF0097A7), "user-highlight-cyan"),
MAGENTA("magenta", Color(0xFFC2185B), "user-highlight-magenta"),
LIME("lime", Color(0xFFAFB42B), "user-highlight-lime"),
PINK("pink", Color(0xFFE91E63), "user-highlight-pink"),
TEAL("teal", Color(0xFF00796B), "user-highlight-teal"),
INDIGO("indigo", Color(0xFF303F9F), "user-highlight-indigo"),
BLACK("black", Color(0xFF424242), "user-highlight-black"),
WHITE("white", Color(0xFFF5F5F5), "user-highlight-white");
}
data class UserHighlight(
val id: String = UUID.randomUUID().toString(),
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int,
val note: String? = null
)
typealias Bookmark = com.aryan.reader.shared.EpubBookmark
typealias HighlightColor = com.aryan.reader.shared.HighlightColor
typealias UserHighlight = com.aryan.reader.shared.UserHighlight
fun escapeJsString(value: String): String {
return value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
return com.aryan.reader.shared.escapeJsString(value)
}
fun saveHighlightPalette(context: Context, palette: List<HighlightColor>) {
@ -146,60 +104,21 @@ fun loadHighlightPalette(context: Context): List<HighlightColor> {
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from ViewModel")
emptyList()
}
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson, chapters.map { it.title })
} else {
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
prefs.getStringSet(key, emptySet()) ?: emptySet()
}
return stringSetToParse.mapNotNull { jsonString ->
try {
val json = JSONObject(jsonString)
val chapterIndex = if (json.has("chapterIndex")) {
json.getInt("chapterIndex")
} else {
val chapterTitle = json.getString("chapterTitle")
chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0)
}
Bookmark(
cfi = json.getString("cfi"),
chapterTitle = json.getString("chapterTitle"),
label = if (json.has("label")) json.getString("label") else null,
snippet = json.getString("snippet"),
pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null,
totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null,
chapterIndex = chapterIndex
)
} catch (_: Exception) {
null
}
}.toSet()
return EpubAnnotationSerializer.parseBookmarkEntries(stringSetToParse, chapters.map { it.title })
}
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
prefs.edit { putString(key, jsonArray.toString()) }
prefs.edit { putString(key, EpubAnnotationSerializer.highlightsToJson(highlights)) }
}
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
@ -207,72 +126,19 @@ fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighl
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonString = prefs.getString(key, "[]") ?: "[]"
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error loading highlights")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun parseHighlightsJson(jsonString: String?): List<UserHighlight> {
if (jsonString.isNullOrBlank()) return emptyList()
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
val noteStr = obj.optString("note", "")
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex"),
note = noteStr.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing highlights JSON")
}
return list
return EpubAnnotationSerializer.parseHighlightsJson(jsonString)
}
fun highlightsToJson(highlights: List<UserHighlight>): String {
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
put("note", h.note ?: "")
}
jsonArray.put(obj)
}
return jsonArray.toString()
return EpubAnnotationSerializer.highlightsToJson(highlights)
}
fun bookmarksToJson(bookmarks: Collection<Bookmark>): String {
return EpubAnnotationSerializer.bookmarksToJson(bookmarks)
}
fun clearHighlightsFromPrefs(context: Context, bookTitle: String) {
@ -291,28 +157,13 @@ fun processAndAddHighlight(
chapterIndex: Int,
currentList: MutableList<UserHighlight>
): String {
// Scenario: Exact match -> Update color and text instead of stacking identical spans
val exactMatchIndex = currentList.indexOfFirst {
it.chapterIndex == chapterIndex && it.cfi == newCfi
}
if (exactMatchIndex != -1) {
val existing = currentList[exactMatchIndex]
currentList[exactMatchIndex] = existing.copy(color = newColor, text = newText)
return existing.cfi
}
// Scenarios: Partial overlap or subsumption -> Add independently
currentList.add(
UserHighlight(
cfi = newCfi,
text = newText,
color = newColor,
chapterIndex = chapterIndex,
note = null
)
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
currentList = currentList
)
return newCfi
}
// --- UI Components ---

View file

@ -167,7 +167,8 @@ enum class ReaderTool(val title: String, val category: String) {
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu")
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@ -282,6 +283,7 @@ fun EpubReaderTopBar(
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
@ -678,6 +680,23 @@ fun EpubReaderTopBar(
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = {
showMoreMenu = false
onOpenTtsReplacements()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
}
}

View file

@ -791,7 +791,8 @@ private fun HighlightsList(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!highlight.note.isNullOrBlank()) {
val note = highlight.note
if (!note.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(8.dp),
@ -799,7 +800,7 @@ private fun HighlightsList(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = highlight.note,
text = note,
style = MaterialTheme.typography.bodySmall.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant

View file

@ -166,6 +166,7 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
@ -177,6 +178,7 @@ import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.CfiUtils
import com.aryan.reader.paginatedreader.HeaderBlock
@ -195,12 +197,16 @@ import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
@ -570,6 +576,7 @@ fun EpubReaderScreen(
}
}
} else null,
stableBookId = uiState.selectedBookId,
viewModel = viewModel
)
}
@ -600,6 +607,7 @@ fun EpubReaderHost(
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
viewModel: MainViewModel
) {
val view = LocalView.current
@ -668,11 +676,16 @@ fun EpubReaderHost(
)
}
val locatorConverter = remember(context) {
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
proto = ProtoBuf { serializersModule = semanticBlockModule },
context = context
context = context,
stableBookId = readerCacheBookId
)
}
@ -698,9 +711,7 @@ fun EpubReaderHost(
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
val bookId = remember(epubBook.title, epubBook.fileName) {
if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val bookId = readerCacheBookId
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
@ -895,6 +906,8 @@ fun EpubReaderHost(
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var chapterToLoadOnSwitch by remember { mutableStateOf<Int?>(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf<Locator?>(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
@ -910,18 +923,7 @@ fun EpubReaderHost(
LaunchedEffect(bookmarks) {
Timber.d("Bookmarks changed, saving...")
val stringSet = bookmarks.map { bookmark ->
JSONObject().apply {
put("cfi", bookmark.cfi)
put("chapterTitle", bookmark.chapterTitle)
put("label", bookmark.label)
put("snippet", bookmark.snippet)
bookmark.pageInChapter?.let { put("pageInChapter", it) }
bookmark.totalPagesInChapter?.let { put("totalPagesInChapter", it) }
put("chapterIndex", bookmark.chapterIndex)
}.toString()
}
onBookmarksChanged(JSONArray(stringSet).toString())
onBookmarksChanged(bookmarksToJson(bookmarks))
}
var activeBookmarkInVerticalView by remember { mutableStateOf<Bookmark?>(null) }
@ -1209,9 +1211,15 @@ fun EpubReaderHost(
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showTtsReplacementsSheet by remember { mutableStateOf(false) }
var showTtsControlsSheet by remember { mutableStateOf(false) }
var showThemePanel by remember { mutableStateOf(false) }
var showPaletteManager by remember { mutableStateOf(false) }
var ttsReplacementPreferences by remember { mutableStateOf(loadTtsReplacementPreferences(context)) }
val updateTtsReplacementPreferences: (ReaderTtsReplacementPreferences) -> Unit = { next ->
ttsReplacementPreferences = next
saveTtsReplacementPreferences(context, next)
}
var currentThemeId by remember { mutableStateOf(loadReaderThemeId(context)) }
var customThemes by remember { mutableStateOf(loadCustomThemes(context)) }
@ -1350,7 +1358,7 @@ fun EpubReaderHost(
}
Timber.tag("TTS_LOCATE")
.d("Saving locator from TTS. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
.d("Saving resolved locator position. chapter=${locator.chapterIndex}, block=${locator.blockIndex}, progress=$progress")
onSavePosition(locator, cfiForWebView, progress)
}
@ -1535,7 +1543,7 @@ fun EpubReaderHost(
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsChapterIndex = chapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1588,7 +1596,11 @@ fun EpubReaderHost(
val relativeOffset = startOffset - target.startOffsetInSource
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
val slicedText = target.text.substring(safeRelativeOffset)
val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset)
val newChunk = target.copy(
text = slicedText,
startOffsetInSource = startOffset,
spokenText = slicedText,
)
val remainingChunks = mutableListOf(newChunk)
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
@ -1599,7 +1611,7 @@ fun EpubReaderHost(
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -1662,7 +1674,9 @@ fun EpubReaderHost(
currentTtsMode = currentTtsMode,
getAuthToken = { viewModel.getAuthToken() },
locatorConverter = locatorConverter,
epubBook = epubBook
epubBook = epubBook,
ttsReplacementPreferences = ttsReplacementPreferences,
ttsReplacementBookId = bookId
)
TtsHighlightHandler(
@ -2045,8 +2059,26 @@ fun EpubReaderHost(
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator) {
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
LaunchedEffect(paginatedPagerState, paginator, currentRenderMode, isPagerInitialized, isPaginatedReconfigurationRestoring) {
if (currentRenderMode != RenderMode.PAGINATED || paginator == null || !isPagerInitialized) {
return@LaunchedEffect
}
snapshotFlow { paginatedPagerState.currentPage }
.collectLatest { page ->
if (!isPaginatedReconfigurationRestoring) {
(paginator as? BookPaginator)?.getLocatorForPage(page)?.let { locator ->
lastKnownLocator = locator
}
}
}
}
LaunchedEffect(paginatedPagerState.currentPage, paginator, isPaginatedReconfigurationRestoring) {
if (currentRenderMode == RenderMode.PAGINATED &&
paginator != null &&
isPagerInitialized &&
!isPaginatedReconfigurationRestoring
) {
delay(1500L)
val pageToSave = paginatedPagerState.currentPage
@ -2164,12 +2196,21 @@ fun EpubReaderHost(
RenderMode.PAGINATED -> {
scope.launch {
val pageToSave = paginatedPagerState.currentPage
val locator = (paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
val pageLocator = if (isPaginatedReconfigurationRestoring) {
null
} else {
(paginator as? BookPaginator)?.getLocatorForPage(pageToSave)
}
val locator = pageLocator ?: paginatedReconfigurationAnchor ?: lastKnownLocator
val chapterIndex = paginator?.findChapterIndexForPage(pageToSave)
if (locator != null && chapterIndex != null) {
if (locator != null) {
val bookPaginator = paginator as? BookPaginator
val progress = if (totalBookLengthChars > 0 && bookPaginator != null) {
val progress = if (pageLocator == null || chapterIndex == null) {
saveResolvedLocatorPosition(locator, null)
onNavigateBack()
return@launch
} else if (totalBookLengthChars > 0 && bookPaginator != null) {
val completedCharsInPreviousChapters = chapters.take(chapterIndex).sumOf { it.plainTextContent.length.toLong() }
val currentPageInChapter = (bookPaginator.chapterStartPageIndices[chapterIndex] ?: 0).let { pageToSave - it }
val charsScrolledInCurrentChapter = bookPaginator.getCharactersScrolledInChapter(chapterIndex, currentPageInChapter)
@ -2186,7 +2227,7 @@ fun EpubReaderHost(
)
onSavePosition(locator, null, progress)
} else {
Timber.w("Final save for paginated view failed. Locator or chapter index is null."
Timber.w("Final save for paginated view failed. Locator is null."
)
}
onNavigateBack()
@ -3570,7 +3611,7 @@ fun EpubReaderHost(
ttsChapterIndex = targetChapterIndex
ttsController.start(
chunks = ttsChunks,
chunks = ttsChunks.withTtsReplacements(ttsReplacementPreferences, bookId),
bookTitle = epubBook.title,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
@ -3886,6 +3927,7 @@ fun EpubReaderHost(
) {
PaginatedReaderScreen(
book = epubBook,
bookId = readerCacheBookId,
isDarkTheme = isDarkTheme,
effectiveBg = effectiveBg,
effectiveText = effectiveText,
@ -3910,7 +3952,18 @@ fun EpubReaderHost(
activeTextureId = activeTextureId,
activeTextureAlpha = activeTextureAlpha,
initialChapterIndexInBook = lastKnownLocator?.chapterIndex,
modifier = Modifier.alpha(if (isPagerInitialized) 1f else 0f),
fallbackLocatorForReconfiguration = paginatedReconfigurationAnchor ?: lastKnownLocator,
onReconfigurationAnchorCaptured = { locator ->
paginatedReconfigurationAnchor = locator
lastKnownLocator = locator
},
onReconfigurationRestoreActiveChanged = { isActive ->
isPaginatedReconfigurationRestoring = isActive
if (!isActive) {
paginatedReconfigurationAnchor = null
}
},
modifier = Modifier.alpha(if (isPagerInitialized && !isPaginatedReconfigurationRestoring) 1f else 0f),
onPaginatorReady = { newPaginator ->
paginator = newPaginator
},
@ -4624,6 +4677,7 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenTtsReplacements = { showTtsReplacementsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenThemeSettings = { showThemePanel = true },
onOpenVisualOptions = { showVisualOptionsSheet = true },
@ -5259,6 +5313,15 @@ fun EpubReaderHost(
)
}
TtsWordReplacementsSheet(
isVisible = showTtsReplacementsSheet,
bookId = bookId,
bookTitle = epubBook.title,
preferences = ttsReplacementPreferences,
onPreferencesChange = updateTtsReplacementPreferences,
onDismiss = { showTtsReplacementsSheet = false },
)
if (showCustomizeToolsSheet) {
CustomizeToolsSheet(
hiddenTools = hiddenTools,

View file

@ -37,9 +37,11 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import com.aryan.reader.withTtsReplacements
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -115,7 +117,9 @@ fun TtsSessionObserver(
currentTtsMode: TtsMode,
getAuthToken: suspend () -> String?,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter, // NEW
epubBook: com.aryan.reader.epub.EpubBook // NEW
epubBook: com.aryan.reader.epub.EpubBook, // NEW
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
val currentRenderModeState = rememberUpdatedState(currentRenderMode)
val loadedChunkCountState = rememberUpdatedState(loadedChunkCount)
@ -131,6 +135,8 @@ fun TtsSessionObserver(
val onTtsChapterIndexChangeState = rememberUpdatedState(onTtsChapterIndexChange)
val locatorConverterState = rememberUpdatedState(locatorConverter) // NEW
val epubBookState = rememberUpdatedState(epubBook) // NEW
val ttsReplacementPreferencesState = rememberUpdatedState(ttsReplacementPreferences)
val ttsReplacementBookIdState = rememberUpdatedState(ttsReplacementBookId)
DisposableEffect(ttsController) {
val job = scope.launch {
@ -168,7 +174,9 @@ fun TtsSessionObserver(
ttsController = ttsController,
scope = this,
locatorConverter = locatorConverterState.value,
epubBook = epubBookState.value
epubBook = epubBookState.value,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
} else if (currentRenderModeState.value == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
@ -182,7 +190,9 @@ fun TtsSessionObserver(
onUpdateTtsChapter = onTtsChapterIndexChangeState.value,
scope = this,
ttsMode = currentTtsMode,
getAuthToken = getAuthToken
getAuthToken = getAuthToken,
ttsReplacementPreferences = ttsReplacementPreferencesState.value,
ttsReplacementBookId = ttsReplacementBookIdState.value
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
@ -303,7 +313,9 @@ private fun handleVerticalAutoAdvance(
ttsController: TtsController,
scope: CoroutineScope,
locatorConverter: com.aryan.reader.paginatedreader.LocatorConverter,
epubBook: com.aryan.reader.epub.EpubBook
epubBook: com.aryan.reader.epub.EpubBook,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex == null) return
@ -323,7 +335,7 @@ private fun handleVerticalAutoAdvance(
val remainingChunks = nativeChunks.subList(resumeIdx + 1, nativeChunks.size)
val token = getAuthToken()
ttsController.start(
chunks = remainingChunks,
chunks = remainingChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
@ -355,7 +367,7 @@ private fun handleVerticalAutoAdvance(
onUpdateTtsChapter(nextIdx)
ttsController.start(
chunks = nativeChunks,
chunks = nativeChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
@ -399,7 +411,9 @@ private fun handlePaginatedAutoAdvance(
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope,
ttsMode: TtsMode,
getAuthToken: suspend () -> String?
getAuthToken: suspend () -> String?,
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
ttsReplacementBookId: String?
) {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < chapters.size - 1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("Paginated: Searching for next TTS content...")
@ -432,7 +446,7 @@ private fun handlePaginatedAutoAdvance(
val token = getAuthToken()
ttsController.start(
chunks = nextChapterChunks,
chunks = nextChapterChunks.withTtsReplacements(ttsReplacementPreferences, ttsReplacementBookId),
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,