Update v1.0.49 (#330)

* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -0,0 +1,378 @@
package com.aryan.reader.desktop
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.ReaderLocator
import com.aryan.reader.shared.UserHighlight
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.ui.ReaderContentNavigationTarget
import com.multiplatform.webview.jsbridge.IJsMessageHandler
import com.multiplatform.webview.jsbridge.JsMessage
import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge
import com.multiplatform.webview.request.RequestInterceptor
import com.multiplatform.webview.request.WebRequest
import com.multiplatform.webview.request.WebRequestInterceptResult
import com.multiplatform.webview.web.LoadingState
import com.multiplatform.webview.web.WebContent
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewNavigator
import com.multiplatform.webview.web.WebViewState
import com.multiplatform.webview.web.rememberWebViewNavigator
import kotlinx.coroutines.launch
import java.awt.AWTEvent
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
@Composable
internal fun DesktopEpubWebView(
html: String,
appearanceScript: String,
navigationTarget: ReaderContentNavigationTarget,
highlights: List<UserHighlight>,
onHighlightCreated: (UserHighlight) -> Unit,
onHighlightSelected: (String) -> Unit,
isFullscreen: Boolean,
onKeyboardNavigation: (DesktopReaderKeyNavigation) -> Unit,
onSelectionAction: (DesktopReaderSelectionAction, String) -> Unit,
onLinkClicked: (DesktopEpubLinkClick) -> Unit,
onVisiblePageChanged: (Int, ReaderLocator?) -> Unit,
onPointerActivity: () -> Unit = {},
networkAccessEnabled: Boolean,
modifier: Modifier = Modifier
) {
val latestOnHighlightCreated by rememberUpdatedState(onHighlightCreated)
val latestOnHighlightSelected by rememberUpdatedState(onHighlightSelected)
val latestOnKeyboardNavigation by rememberUpdatedState(onKeyboardNavigation)
val latestOnSelectionAction by rememberUpdatedState(onSelectionAction)
val latestOnLinkClicked by rememberUpdatedState(onLinkClicked)
val latestOnVisiblePageChanged by rememberUpdatedState(onVisiblePageChanged)
val latestOnPointerActivity by rememberUpdatedState(onPointerActivity)
val scope = rememberCoroutineScope()
val linkRequestInterceptor = remember(scope, networkAccessEnabled) {
object : RequestInterceptor {
override fun onInterceptUrlRequest(
request: WebRequest,
navigator: WebViewNavigator
): WebRequestInterceptResult {
if (!networkAccessEnabled && request.url.isRemoteNetworkUrl()) {
logEpubLink("request_blocked_offline url=\"${request.url.logPreview()}\"")
return WebRequestInterceptResult.Reject
}
if (!request.isForMainFrame) return WebRequestInterceptResult.Allow
val link = request.url.readerLinkClickFromIntercept() ?: return WebRequestInterceptResult.Allow
logEpubLink(
"request_intercept method=${request.method} redirect=${request.isRedirect} " +
"url=\"${request.url.logPreview()}\" href=\"${link.href.logPreview()}\""
)
scope.launch {
latestOnLinkClicked(link.copy(source = "request"))
}
return WebRequestInterceptResult.Reject
}
}
}
val navigator = rememberWebViewNavigator(requestInterceptor = linkRequestInterceptor)
val bridge = rememberWebViewJsBridge()
DisposableEffect(bridge) {
val handlers = listOf(
desktopEpubBridgeHandler("readerHighlightCreated") { message ->
val highlight = EpubAnnotationSerializer.parseHighlightJsonLenient(message.params)
if (highlight == null) {
logEpubSelectionDebug("highlight_parse_failed params=${message.params.logPreview(900)}")
} else {
scope.launch { latestOnHighlightCreated(highlight) }
}
},
desktopEpubBridgeHandler("readerHighlightClicked") { message ->
message.params.readerHighlightClickOrNull()?.let { highlightClick ->
scope.launch { latestOnHighlightSelected(highlightClick.highlightId) }
}
},
desktopEpubBridgeHandler("readerPositionChanged") { message ->
message.params.readerPositionOrNull()?.let { position ->
scope.launch { latestOnVisiblePageChanged(position.pageIndex, position.locator) }
}
},
desktopEpubBridgeHandler("readerSelectionAction") { message ->
val selectionAction = message.params.readerSelectionActionOrNull()
if (selectionAction != null) {
scope.launch { latestOnSelectionAction(selectionAction.action, selectionAction.text) }
}
},
desktopEpubBridgeHandler("readerKeyNavigation") { message ->
message.params.readerKeyNavigationOrNull()?.let { action ->
scope.launch { latestOnKeyboardNavigation(action) }
}
},
desktopEpubBridgeHandler("readerPointerActivity") { _ ->
scope.launch { latestOnPointerActivity() }
},
desktopEpubBridgeHandler("readerTtsHighlightLog") { message ->
logDesktopTts("epub_highlight_js ${message.params.logPreview(500)}")
},
desktopEpubBridgeHandler("readerSelectionDebugLog") { message ->
logEpubSelectionDebug(message.params.readerSelectionDebugMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerPaginationLayoutLog") { message ->
logEpubPagination(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerGapLayoutLog") { message ->
logReaderGap(message.params.readerPaginationLogMessageOrNull() ?: message.params.logPreview(900))
},
desktopEpubBridgeHandler("readerLinkClicked") { message ->
logEpubLink("bridge_message params=\"${message.params.logPreview()}\"")
val link = message.params.readerLinkClickOrNull()
if (link == null) {
logEpubLink("bridge_message_ignored reason=parse_failed")
} else {
logEpubLink(
"bridge_message_parsed href=\"${link.href.logPreview()}\" " +
"chapterIndex=${link.chapterIndex} chapterHref=\"${link.chapterHref.orEmpty().logPreview()}\""
)
scope.launch { latestOnLinkClicked(link) }
}
}
)
handlers.forEach { bridge.register(it) }
onDispose {
handlers.forEach { bridge.unregister(it) }
}
}
val state = remember {
WebViewState(
WebContent.Data(
data = html,
baseUrl = null,
encoding = "utf-8",
mimeType = "text/html",
historyUrl = null
)
)
}
LaunchedEffect(html) {
navigator.loadHtml(
html = html,
baseUrl = null,
mimeType = "text/html",
encoding = "utf-8",
historyUrl = null
)
}
DisposableEffect(Unit) {
var lastActivityAt = 0L
var lastMouseX: Int? = null
var lastMouseY: Int? = null
val listener = AWTEventListener { event ->
val mouseEvent = event as? MouseEvent ?: return@AWTEventListener
if (
mouseEvent.id != MouseEvent.MOUSE_MOVED &&
mouseEvent.id != MouseEvent.MOUSE_DRAGGED &&
mouseEvent.id != MouseEvent.MOUSE_PRESSED &&
mouseEvent.id != MouseEvent.MOUSE_WHEEL
) {
return@AWTEventListener
}
if (mouseEvent.id == MouseEvent.MOUSE_MOVED || mouseEvent.id == MouseEvent.MOUSE_DRAGGED) {
val screenX = mouseEvent.xOnScreen
val screenY = mouseEvent.yOnScreen
if (lastMouseX == screenX && lastMouseY == screenY) return@AWTEventListener
lastMouseX = screenX
lastMouseY = screenY
} else {
lastMouseX = mouseEvent.xOnScreen
lastMouseY = mouseEvent.yOnScreen
}
val now = mouseEvent.`when`.takeIf { it > 0L } ?: System.currentTimeMillis()
if (now - lastActivityAt < 120L) return@AWTEventListener
lastActivityAt = now
scope.launch { latestOnPointerActivity() }
}
val eventMask = AWTEvent.MOUSE_MOTION_EVENT_MASK or
AWTEvent.MOUSE_EVENT_MASK or
AWTEvent.MOUSE_WHEEL_EVENT_MASK
Toolkit.getDefaultToolkit().addAWTEventListener(listener, eventMask)
onDispose {
Toolkit.getDefaultToolkit().removeAWTEventListener(listener)
}
}
Box(modifier = modifier) {
WebView(
state = state,
modifier = Modifier.fillMaxSize(),
captureBackPresses = false,
navigator = navigator,
webViewJsBridge = bridge
)
LaunchedEffect(state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(DesktopEpubKeyNavigationScript)
}
LaunchedEffect(isFullscreen, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerDesktopFullscreen = ${if (isFullscreen) "true" else "false"};")
}
LaunchedEffect(html, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript("window.readerPaginationLayoutLog && window.readerPaginationLayoutLog('desktop_finished');")
}
LaunchedEffect(appearanceScript, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
navigator.evaluateJavaScript(appearanceScript)
}
LaunchedEffect(
navigationTarget.autoScroll,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val autoScroll = navigationTarget.autoScroll.sanitized()
val command = if (autoScroll.enabled) {
"window.readerAutoScroll && window.readerAutoScroll.start(${autoScroll.speed});"
} else {
"window.readerAutoScroll && window.readerAutoScroll.stop();"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(
navigationTarget.requestId,
navigationTarget.readingMode,
state.loadingState
) {
if (navigationTarget.readingMode != ReaderReadingMode.VERTICAL) return@LaunchedEffect
if (!state.loadingState.isFinished()) return@LaunchedEffect
val locator = navigationTarget.locator ?: return@LaunchedEffect
navigator.evaluateJavaScript("window.readerScrollToLocator && window.readerScrollToLocator(${locator.toReaderLocatorJson()});")
}
LaunchedEffect(
navigationTarget.ttsRequestId,
navigationTarget.ttsLocator,
navigationTarget.readingMode,
state.loadingState
) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
val locator = navigationTarget.ttsLocator
val command = if (locator == null) {
logDesktopTts(
"epub_highlight_command clear mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId}"
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(null, false);"
} else {
val follow = navigationTarget.readingMode == ReaderReadingMode.VERTICAL
logDesktopTts(
"epub_highlight_command set mode=${navigationTarget.readingMode} request=${navigationTarget.ttsRequestId} " +
"follow=$follow chapter=${locator.chapterIndex} page=${locator.pageIndex} " +
"offsets=${locator.startOffset}..${locator.endOffset} cfi=\"${locator.cfi.orEmpty().logPreview()}\" " +
"text=\"${locator.textQuote.orEmpty().logPreview()}\""
)
"window.readerSetTtsLocator && window.readerSetTtsLocator(${locator.toReaderLocatorJson()}, $follow);"
}
navigator.evaluateJavaScript(command)
}
LaunchedEffect(highlights, state.loadingState) {
if (!state.loadingState.isFinished()) return@LaunchedEffect
val highlightsJson = EpubAnnotationSerializer.highlightsToJson(highlights)
navigator.evaluateJavaScript("window.readerApplyHighlights && window.readerApplyHighlights($highlightsJson);")
}
val loadingState = state.loadingState
if (loadingState is LoadingState.Loading) {
LinearProgressIndicator(
progress = { loadingState.progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}
private fun desktopEpubBridgeHandler(
methodName: String,
onMessage: (JsMessage) -> Unit
): IJsMessageHandler {
return object : IJsMessageHandler {
override fun methodName(): String = methodName
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
onMessage(message)
}
}
}
private fun LoadingState.isFinished(): Boolean = this is LoadingState.Finished
private val DesktopEpubKeyNavigationScript = """
(function () {
if (!window.readerDesktopPointerActivityInstalled) {
window.readerDesktopPointerActivityInstalled = true;
var lastPointerActivityAt = 0;
var lastPointerX = null;
var lastPointerY = null;
function notifyPointerActivity(event, requireMovement) {
if (requireMovement && event) {
var x = Math.round(event.screenX || event.clientX || 0);
var y = Math.round(event.screenY || event.clientY || 0);
if (lastPointerX === x && lastPointerY === y) return;
lastPointerX = x;
lastPointerY = y;
}
var now = Date.now();
if (now - lastPointerActivityAt < 120) return;
lastPointerActivityAt = now;
if (!window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
window.kmpJsBridge.callNative('readerPointerActivity', '{}');
}
document.addEventListener('mousemove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointermove', function (event) { notifyPointerActivity(event, true); }, true);
document.addEventListener('pointerdown', function (event) { notifyPointerActivity(event, false); }, true);
document.addEventListener('wheel', function (event) { notifyPointerActivity(event, false); }, true);
}
if (window.readerDesktopKeyNavigationInstalled) return;
window.readerDesktopKeyNavigationInstalled = true;
document.addEventListener('keydown', function (event) {
var target = event.target;
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
if (target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select')) return;
var action = null;
if (event.ctrlKey && (event.key === 'f' || event.key === 'F')) action = 'search';
else if (event.ctrlKey && (event.key === 'g' || event.key === 'G')) action = 'nextSearch';
else if (event.key === 'ArrowRight' || event.key === 'PageDown') action = 'next';
else if (event.key === 'ArrowLeft' || event.key === 'PageUp') action = 'previous';
else if (event.key === 'Home') action = 'first';
else if (event.key === 'End') action = 'last';
else if (event.key === 'Escape' && window.readerDesktopFullscreen) action = 'exitFullscreen';
if (!action || !window.kmpJsBridge || !window.kmpJsBridge.callNative) return;
event.preventDefault();
event.stopPropagation();
window.kmpJsBridge.callNative('readerKeyNavigation', JSON.stringify({ action: action }));
}, true);
})();
""".trimIndent()