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:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue