Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -29,6 +29,7 @@ import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
@ -90,6 +91,34 @@ import java.io.BufferedReader
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
"ContentBridge",
"HighlightBridge",
"AutoScrollBridge",
"CfiBridge",
"SnippetBridge",
"TtsBridge",
"AiBridge",
"FootnoteBridge",
"LinkNavBridge"
)
private fun WebView.releaseReaderResources() {
try {
stopLoading()
READER_WEB_VIEW_JS_INTERFACES.forEach { removeJavascriptInterface(it) }
webChromeClient = null
webViewClient = WebViewClient()
loadDataWithBaseURL(null, "", "text/html", "UTF-8", null)
clearHistory()
removeAllViews()
destroy()
} catch (e: Exception) {
Timber.w(e, "Failed to fully release EPUB WebView resources")
}
}
private fun getFontCssInjection(): String {
return """
@ -314,12 +343,14 @@ class FootnoteJsBridge(
@Suppress("unused")
class LinkNavJsBridge(
private val currentChapterTitle: String
private val currentChapterTitle: String,
private val onInternalLinkClick: (String) -> Unit
) {
@JavascriptInterface
fun onLinkClicked(href: String, epubType: String, linkText: String) {
Timber.tag(TAG_LINK_NAV)
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
onInternalLinkClick(href)
}
}
@ -384,6 +415,7 @@ fun ChapterWebView(
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
onWebViewDisposed: (WebView) -> Unit = {},
activeTextureId: String? = null,
activeTextureAlpha: Float = 0.55f
) {
@ -554,7 +586,7 @@ fun ChapterWebView(
}, "AutoScrollBridge"
)
webChromeClient = object : android.webkit.WebChromeClient() {
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
val message = it.message()
@ -668,7 +700,9 @@ fun ChapterWebView(
)
addJavascriptInterface(
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
LinkNavJsBridge(chapterTitle) { href ->
this.post { onInternalLinkClick(href) }
}, "LinkNavBridge"
)
webViewClient = object : WebViewClient() {
@ -744,6 +778,37 @@ fun ChapterWebView(
null
)
view?.evaluateJavascript(
"""
javascript:(function() {
if (window.__readerInternalLinkBridgeInstalled) return;
window.__readerInternalLinkBridgeInstalled = true;
document.addEventListener('click', function(event) {
var target = event.target;
var anchor = target && target.closest ? target.closest('a[href]') : null;
if (!anchor && target && target.parentElement && target.parentElement.closest) {
anchor = target.parentElement.closest('a[href]');
}
if (!anchor) return;
var rawHref = anchor.getAttribute('href') || '';
if (!rawHref) return;
if (/^(https?:|mailto:|tel:|javascript:)/i.test(rawHref)) return;
if (/^\/\//.test(rawHref)) return;
event.preventDefault();
var resolvedHref = anchor.href || rawHref;
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
window.LinkNavBridge.onLinkClicked(
resolvedHref,
anchor.getAttribute('epub:type') || '',
anchor.textContent || ''
);
}
}, true);
})();
""".trimIndent(),
null
)
view?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.restoreHighlights('${
escapeJsString(
@ -907,10 +972,19 @@ fun ChapterWebView(
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
}
webView
}, update = { webView ->
Timber.d(
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
)
},
modifier = Modifier.fillMaxSize(),
onRelease = { releasedWebView ->
if (localWebViewRef === releasedWebView) {
localWebViewRef = null
}
customMenuState?.finishActionModeCallback?.invoke()
customMenuState = null
onWebViewDisposed(releasedWebView)
releasedWebView.releaseReaderResources()
},
update = { webView ->
Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}")
localWebViewRef = webView
onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ")
@ -946,7 +1020,7 @@ fun ChapterWebView(
"javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);",
null
)
}, modifier = Modifier.fillMaxSize()
}
)
}

View file

@ -72,6 +72,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
@ -88,6 +89,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ScreenRotation
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Visibility
@ -104,6 +106,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -166,8 +169,9 @@ enum class ReaderTool(val title: String, val category: String) {
PAGE_TURN_ANIM("Realistic Page Turns", "Overflow Menu"),
KEEP_SCREEN_ON("Keep Screen On", "Overflow Menu"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
SCREEN_ORIENTATION("Screen Orientation", "Top Bar"),
AUTO_SCROLL("Auto Scroll", "Overflow Menu"),
TTS_SETTINGS("TTS Voice Settings", "Overflow Menu"),
TTS_SETTINGS("TTS Settings", "Overflow Menu"),
TTS_REPLACEMENTS("TTS Word Replacements", "Overflow Menu")
}
@ -258,7 +262,8 @@ private val epubToolbarTools = setOf(
ReaderTool.FORMAT,
ReaderTool.SEARCH,
ReaderTool.AI_FEATURES,
ReaderTool.TTS_CONTROLS
ReaderTool.TTS_CONTROLS,
ReaderTool.SCREEN_ORIENTATION
)
@Composable
@ -272,6 +277,7 @@ fun EpubReaderTopBar(
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
@ -281,12 +287,14 @@ fun EpubReaderTopBar(
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onSetRightToLeftPagination: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenVisualOptions: () -> Unit,
onOpenScreenOrientation: () -> Unit,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
@ -414,17 +422,32 @@ fun EpubReaderTopBar(
tint = if (isTtsActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onOpenScreenOrientation
) {
Icon(
Icons.Default.ScreenRotation,
contentDescription = stringResource(R.string.menu_screen_orientation),
tint = MaterialTheme.colorScheme.onSurface
)
}
else -> Unit
}
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
var showHiddenToolsExpanded by remember { mutableStateOf(false) }
var showReadingModeExpanded by remember { mutableStateOf(false) }
var showTtsSettingsExpanded by remember { mutableStateOf(false) }
TooltipIconButton(
text = stringResource(R.string.tooltip_more_options),
description = stringResource(R.string.tooltip_more_options_desc),
onClick = {
showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showMoreMenu = true
}
) {
@ -435,6 +458,8 @@ fun EpubReaderTopBar(
expanded = showMoreMenu,
onDismissRequest = {
showHiddenToolsExpanded = false
showReadingModeExpanded = false
showTtsSettingsExpanded = false
showMoreMenu = false
}
) {
@ -480,7 +505,8 @@ fun EpubReaderTopBar(
onToggleFormat = onToggleFormat,
onToggleSearch = onToggleSearch,
onOpenAiHub = onOpenAiHub,
onToggleTts = onToggleTts
onToggleTts = onToggleTts,
onOpenScreenOrientation = onOpenScreenOrientation
)
}
}
@ -528,31 +554,63 @@ fun EpubReaderTopBar(
if (!hiddenTools.contains(ReaderTool.READING_MODE.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
text = { Text(stringResource(R.string.menu_change_reading_mode)) },
onClick = { showReadingModeExpanded = !showReadingModeExpanded },
trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(if (showReadingModeExpanded) 180f else 0f)
)
})
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
})
}
)
if (showReadingModeExpanded) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
})
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_reading_mode_paginated)) },
enabled = !isTtsActive,
onClick = {
onSetRightToLeftPagination(false)
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED && !isRightToLeftPagination) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
}
})
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_right_to_left_pagination)) },
enabled = !isTtsActive,
onClick = {
onSetRightToLeftPagination(true)
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = {
if (currentRenderMode == RenderMode.PAGINATED && isRightToLeftPagination) {
Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
}
})
}
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.BOOKMARK.name)) {
@ -664,39 +722,62 @@ fun EpubReaderTopBar(
})
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)) {
val showTtsVoiceSettings = !hiddenTools.contains(ReaderTool.TTS_SETTINGS.name)
val showTtsReplacements = !hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
if (showTtsVoiceSettings || showTtsReplacements) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
text = { Text(stringResource(R.string.menu_tts_settings)) },
onClick = { showTtsSettingsExpanded = !showTtsSettingsExpanded },
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_word_replacements)) },
onClick = {
showMoreMenu = false
onOpenTtsReplacements()
},
leadingIcon = {
trailingIcon = {
Icon(
Icons.Default.GraphicEq,
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(20.dp)
modifier = Modifier.rotate(if (showTtsSettingsExpanded) 180f else 0f)
)
}
)
if (showTtsSettingsExpanded) {
if (showTtsVoiceSettings) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_voice_settings)) },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onOpenTtsSettings()
},
leadingIcon = {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
}
if (showTtsReplacements) {
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)
)
}
)
}
}
}
}
}
@ -706,6 +787,89 @@ fun EpubReaderTopBar(
}
}
@Composable
fun EpubJumpHistoryBar(
modifier: Modifier = Modifier,
showStandardBars: Boolean,
searchStateActive: Boolean,
backLabel: String?,
forwardLabel: String?,
onBack: () -> Unit,
onForward: () -> Unit,
onClear: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive && (backLabel != null || forwardLabel != null),
enter = slideInVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeIn(animationSpec = tween(200)),
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = onBack,
enabled = backLabel != null,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_jump_back),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = backLabel.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = onClear,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.action_clear),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.action_clear), maxLines = 1)
}
TextButton(
onClick = onForward,
enabled = forwardLabel != null,
modifier = Modifier.weight(1f)
) {
Text(
text = forwardLabel.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.content_desc_jump_forward),
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun EpubReaderBottomBar(
@ -723,6 +887,7 @@ fun EpubReaderBottomBar(
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit,
hiddenTools: Set<String>,
toolOrder: List<ReaderTool>,
bottomTools: Set<String>,
@ -835,6 +1000,16 @@ fun EpubReaderBottomBar(
tint = if (isTtsSessionActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
ReaderTool.SCREEN_ORIENTATION -> TooltipIconButton(
text = stringResource(R.string.menu_screen_orientation),
description = stringResource(R.string.visual_options_screen_orientation_desc),
onClick = onOpenScreenOrientation
) {
Icon(
imageVector = Icons.Default.ScreenRotation,
contentDescription = stringResource(R.string.menu_screen_orientation)
)
}
else -> Unit
}
}
@ -1849,6 +2024,7 @@ private fun ToolPreviewIcon(tool: ReaderTool) {
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = tool.title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = tool.title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = tool.title, modifier = Modifier.size(20.dp))
}
}
@ -1866,7 +2042,8 @@ private fun HiddenEpubToolMenuItem(
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onOpenAiHub: () -> Unit,
onToggleTts: () -> Unit
onToggleTts: () -> Unit,
onOpenScreenOrientation: () -> Unit
) {
val enabled = when (tool) {
ReaderTool.SLIDER -> currentRenderMode != RenderMode.VERTICAL_SCROLL
@ -1886,6 +2063,7 @@ private fun HiddenEpubToolMenuItem(
ReaderTool.SEARCH -> onToggleSearch()
ReaderTool.AI_FEATURES -> onOpenAiHub()
ReaderTool.TTS_CONTROLS -> onToggleTts()
ReaderTool.SCREEN_ORIENTATION -> onOpenScreenOrientation()
else -> Unit
}
},

View file

@ -26,12 +26,13 @@ import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.MenuInflater
import android.webkit.WebView
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.PopupMenu
import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
@ -59,7 +60,143 @@ class InteractiveWebView(
private val scrollStopHandler = Handler(Looper.getMainLooper())
private var scrollStopRunnable: Runnable? = null
private var mCustomCallback: ActionMode.Callback? = null
private var activeSelectionActionMode: ActionMode? = null
private fun clearPendingSelectionWork() {
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
scrollStopRunnable = null
}
private fun startLocalSelectionActionMode(): ActionMode {
activeSelectionActionMode?.let { existingMode ->
showCustomSelectionMenuFromCurrentSelection(existingMode)
return existingMode
}
lateinit var localMode: ActionMode
localMode = LocalSelectionActionMode(this) {
if (activeSelectionActionMode === localMode) {
activeSelectionActionMode = null
}
onHideCustomSelectionMenu()
}
activeSelectionActionMode = localMode
showCustomSelectionMenuFromCurrentSelection(localMode)
return localMode
}
private fun finishLocalSelectionActionMode() {
activeSelectionActionMode?.finish()
activeSelectionActionMode = null
}
private fun showCustomSelectionMenuFromCurrentSelection(mode: ActionMode) {
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (activeSelectionActionMode !== mode) {
return@evaluateJavascript
}
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
mode.finish()
}
}
}
private val gestureDetector =
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@ -167,148 +304,12 @@ class InteractiveWebView(
return super.onTouchEvent(event)
}
// MIUI can crash inside FloatingToolbar when WindowInsets are null, so WebView
// selections use the app's Compose popup without starting the platform toolbar.
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
if (type == ActionMode.TYPE_FLOATING) {
if (mCustomCallback == null) {
mCustomCallback = object : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onCreateActionMode")
menu.clear()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onPrepareActionMode")
menu.clear()
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
onHideCustomSelectionMenu()
mode.finish()
}
}
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
return false
}
override fun onDestroyActionMode(mode: ActionMode) {
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
onHideCustomSelectionMenu()
}
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
super.onGetContentRect(mode, view, outRect)
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
}
}
}
return super.startActionMode(mCustomCallback, type)
Timber.d("CustomSelection: handling floating action mode locally.")
return startLocalSelectionActionMode()
}
return super.startActionMode(originalCallback, type)
}
@ -316,18 +317,79 @@ class InteractiveWebView(
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
clearPendingSelectionWork()
scrollStopRunnable = Runnable {
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank()) {
Timber.d("Selection exists after scroll. Restarting action mode.")
mCustomCallback?.let {
startActionMode(it, ActionMode.TYPE_FLOATING)
}
startLocalSelectionActionMode()
}
}
}
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
}
}
override fun onDetachedFromWindow() {
clearPendingSelectionWork()
finishLocalSelectionActionMode()
super.onDetachedFromWindow()
}
override fun destroy() {
clearPendingSelectionWork()
finishLocalSelectionActionMode()
super.destroy()
}
private class LocalSelectionActionMode(
anchorView: View,
private val onFinished: () -> Unit
) : ActionMode() {
private val modeContext = anchorView.context
private val menu: Menu = PopupMenu(modeContext, anchorView).menu
private val menuInflater = MenuInflater(modeContext)
private var title: CharSequence? = null
private var subtitle: CharSequence? = null
private var customView: View? = null
private var finished = false
override fun setTitle(title: CharSequence?) {
this.title = title
}
override fun setTitle(resId: Int) {
title = modeContext.getText(resId)
}
override fun setSubtitle(subtitle: CharSequence?) {
this.subtitle = subtitle
}
override fun setSubtitle(resId: Int) {
subtitle = modeContext.getText(resId)
}
override fun setCustomView(view: View?) {
customView = view
}
override fun invalidate() = Unit
override fun finish() {
if (finished) return
finished = true
onFinished()
}
override fun getMenu(): Menu = menu
override fun getTitle(): CharSequence? = title
override fun getSubtitle(): CharSequence? = subtitle
override fun getCustomView(): View? = customView
override fun getMenuInflater(): MenuInflater = menuInflater
}
}