General improvements (#316)
* Added support for right text alignment in epub reader * Added tabs management to PDF navigation drawer * Implemented unified selection menu placement logic * Added reset functionality for toolbar customization * Improved highlight filtering in epub pagination * Migrated hardcoded UI strings to string resources * Extracted desktop application logic from Main.kt into modular files * Refactored Main.kt by extracting PDF and EPUB logic into specialized files * Added Spanish language support * Added system default option to app language selection
This commit is contained in:
parent
759d4b73a0
commit
056485a140
77 changed files with 9184 additions and 5947 deletions
|
|
@ -0,0 +1,532 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.aryan.reader.shared.AppContrastOption
|
||||
import com.aryan.reader.shared.AppThemeMode
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.shared.ui.SharedAppTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.Component
|
||||
import java.awt.EventQueue
|
||||
import java.awt.Frame
|
||||
import java.awt.GraphicsDevice
|
||||
import java.awt.KeyboardFocusManager
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Toolkit
|
||||
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
internal val DesktopDefaultAppSeedColor = Color(0xFFFFB300)
|
||||
|
||||
internal fun launchEpistemeDesktopApplication(startupSplash: DesktopStartupSplash? = null) {
|
||||
configureComposeSwingInterop()
|
||||
application {
|
||||
val windowDefaults = remember { epistemeDesktopWindowDefaults() }
|
||||
val windowStateStore = remember { DesktopWindowStateStore() }
|
||||
val restoredWindowState = remember { windowStateStore.load() }
|
||||
val windowState = rememberWindowState(
|
||||
placement = restoredWindowState?.toWindowPlacement()
|
||||
?: DesktopWindowStateSnapshot.default().toWindowPlacement(),
|
||||
position = restoredWindowState?.toWindowPosition() ?: WindowPosition(Alignment.Center),
|
||||
size = restoredWindowState?.toWindowSize(windowDefaults.defaultSize) ?: windowDefaults.defaultSize
|
||||
)
|
||||
var readerFullscreen by remember { mutableStateOf(false) }
|
||||
DesktopWindowStatePersistenceEffect(
|
||||
windowState = windowState,
|
||||
store = windowStateStore,
|
||||
enabled = !readerFullscreen
|
||||
)
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = windowDefaults.title,
|
||||
state = windowState,
|
||||
icon = painterResource(windowDefaults.iconResourcePath)
|
||||
) {
|
||||
DisposableEffect(window, windowDefaults.minimumSize) {
|
||||
window.minimumSize = windowDefaults.minimumSize
|
||||
onDispose {
|
||||
startupSplash?.close()
|
||||
}
|
||||
}
|
||||
EpistemeDesktopStartupGate(
|
||||
window = window,
|
||||
startupSplash = startupSplash,
|
||||
appWindowPlacement = windowState.placement,
|
||||
readerFullscreen = readerFullscreen,
|
||||
onReaderFullscreenChange = { readerFullscreen = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpistemeDesktopStartupGate(
|
||||
window: Component?,
|
||||
startupSplash: DesktopStartupSplash?,
|
||||
appWindowPlacement: WindowPlacement,
|
||||
readerFullscreen: Boolean,
|
||||
onReaderFullscreenChange: (Boolean) -> Unit
|
||||
) {
|
||||
var showApp by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(startupSplash) {
|
||||
onDispose {
|
||||
startupSplash?.close()
|
||||
}
|
||||
}
|
||||
|
||||
if (showApp) {
|
||||
EpistemeDesktopApp(
|
||||
window = window,
|
||||
appWindowPlacement = appWindowPlacement,
|
||||
readerFullscreen = readerFullscreen,
|
||||
onReaderFullscreenChange = onReaderFullscreenChange
|
||||
)
|
||||
} else {
|
||||
EpistemeDesktopStartupScreen(window = window)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
startupSplash?.close()
|
||||
delay(80L)
|
||||
showApp = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpistemeDesktopStartupScreen(window: Component?) {
|
||||
val appTitle = remember { epistemeDesktopWindowDefaults().title }
|
||||
SharedAppTheme(
|
||||
appThemeMode = AppThemeMode.SYSTEM,
|
||||
appContrastOption = AppContrastOption.STANDARD,
|
||||
appTextDimFactorLight = 1.0f,
|
||||
appTextDimFactorDark = 1.0f,
|
||||
appSeedColor = DesktopDefaultAppSeedColor
|
||||
) {
|
||||
EpistemeDesktopWindowChromeEffect(
|
||||
window = window,
|
||||
captionColor = MaterialTheme.colorScheme.surface,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
borderColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(EpistemeDesktopWindowIconResource),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = appTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = "Opening your library",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val ComposeInteropBlendingProperty = "compose.interop.blending"
|
||||
internal const val ComposeInteropBlendingEnabled = "true"
|
||||
private const val DesktopWindowStatePersistDebounceMillis = 450L
|
||||
|
||||
internal fun configureComposeSwingInterop() {
|
||||
// Must run before Compose creates the desktop window. Vertical EPUB embeds a Swing-backed
|
||||
// JCEF WebView, and current Compose interop can leave a stale black native rectangle after
|
||||
// that reader surface is removed unless interop blending is enabled.
|
||||
if (System.getProperty(ComposeInteropBlendingProperty).isNullOrBlank()) {
|
||||
System.setProperty(ComposeInteropBlendingProperty, ComposeInteropBlendingEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopWindowStatePersistenceEffect(
|
||||
windowState: WindowState,
|
||||
store: DesktopWindowStateStore,
|
||||
enabled: Boolean
|
||||
) {
|
||||
val persistenceEnabled by rememberUpdatedState(enabled)
|
||||
LaunchedEffect(windowState, store) {
|
||||
snapshotFlow { DesktopWindowStateSnapshot.fromWindowState(windowState) }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { snapshot ->
|
||||
if (!persistenceEnabled || snapshot == null) return@collectLatest
|
||||
delay(DesktopWindowStatePersistDebounceMillis)
|
||||
if (persistenceEnabled) {
|
||||
withContext(Dispatchers.IO) {
|
||||
store.save(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderFullscreenEffect(
|
||||
window: Component?,
|
||||
enabled: Boolean
|
||||
) {
|
||||
val awtWindow = window as? java.awt.Window ?: return
|
||||
val fullscreenSnapshot = remember(awtWindow) {
|
||||
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||
}
|
||||
val pendingExitSnapshot = remember(awtWindow) {
|
||||
AtomicReference<DesktopReaderFullscreenSnapshot?>()
|
||||
}
|
||||
|
||||
LaunchedEffect(awtWindow, enabled) {
|
||||
if (!enabled && fullscreenSnapshot.get() == null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (enabled) {
|
||||
EventQueue.invokeLater {
|
||||
awtWindow.captureDesktopReaderFullscreenSnapshot(fullscreenSnapshot)
|
||||
}
|
||||
}
|
||||
delay(if (enabled) 180L else 80L)
|
||||
EventQueue.invokeLater {
|
||||
if (enabled) {
|
||||
pendingExitSnapshot.set(null)
|
||||
awtWindow.enterDesktopReaderFullscreen(fullscreenSnapshot)
|
||||
} else {
|
||||
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot, pendingExitSnapshot)
|
||||
}
|
||||
}
|
||||
delay(120L)
|
||||
EventQueue.invokeLater {
|
||||
if (!enabled) {
|
||||
awtWindow.restoreDesktopReaderFullscreenExitBounds(pendingExitSnapshot.getAndSet(null))
|
||||
}
|
||||
awtWindow.refreshDesktopReaderWindowFocus()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(awtWindow) {
|
||||
onDispose {
|
||||
EventQueue.invokeLater {
|
||||
awtWindow.beginDesktopReaderFullscreenExit(fullscreenSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class DesktopReaderFullscreenSnapshot(
|
||||
val device: GraphicsDevice?,
|
||||
val frameState: Int?,
|
||||
val frameBounds: Rectangle?,
|
||||
val alwaysOnTop: Boolean
|
||||
)
|
||||
|
||||
private fun java.awt.Window.enterDesktopReaderFullscreen(
|
||||
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||
) {
|
||||
if (!isDisplayable) return
|
||||
focusableWindowState = true
|
||||
captureDesktopReaderFullscreenSnapshot(snapshotRef)
|
||||
applyDesktopReaderBorderlessFullscreen(snapshotRef.get())
|
||||
refreshDesktopReaderWindowFocus()
|
||||
}
|
||||
|
||||
private fun java.awt.Window.captureDesktopReaderFullscreenSnapshot(
|
||||
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>
|
||||
) {
|
||||
snapshotRef.compareAndSet(
|
||||
null,
|
||||
DesktopReaderFullscreenSnapshot(
|
||||
device = graphicsConfiguration?.device,
|
||||
frameState = (this as? Frame)?.extendedState,
|
||||
frameBounds = bounds.desktopReaderCopy(),
|
||||
alwaysOnTop = isAlwaysOnTop
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun java.awt.Window.applyDesktopReaderBorderlessFullscreen(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||
if (!isDisplayable) return
|
||||
val device = snapshot?.device ?: graphicsConfiguration?.device
|
||||
val frame = this as? Frame
|
||||
focusableWindowState = true
|
||||
if (frame != null) {
|
||||
frame.extendedState = frame.extendedState and Frame.ICONIFIED.inv() and Frame.MAXIMIZED_BOTH.inv()
|
||||
frame.state = Frame.NORMAL
|
||||
}
|
||||
device?.let { fullscreenDevice ->
|
||||
runCatching {
|
||||
if (fullscreenDevice.fullScreenWindow == this) {
|
||||
fullscreenDevice.fullScreenWindow = null
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
bounds = device?.desktopReaderScreenBounds() ?: graphicsConfiguration?.bounds?.desktopReaderCopy() ?: bounds
|
||||
}
|
||||
runCatching {
|
||||
isAlwaysOnTop = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.awt.Window.beginDesktopReaderFullscreenExit(
|
||||
snapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>,
|
||||
pendingExitSnapshotRef: AtomicReference<DesktopReaderFullscreenSnapshot?>? = null
|
||||
) {
|
||||
val snapshot = snapshotRef.getAndSet(null)
|
||||
if (snapshot == null) return
|
||||
pendingExitSnapshotRef?.set(snapshot)
|
||||
val device = snapshot.device ?: graphicsConfiguration?.device
|
||||
device?.let { fullscreenDevice ->
|
||||
runCatching {
|
||||
if (fullscreenDevice.fullScreenWindow == this) {
|
||||
fullscreenDevice.fullScreenWindow = null
|
||||
}
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||
}
|
||||
if (!isVisible) {
|
||||
isVisible = true
|
||||
}
|
||||
(this as? Frame)?.let { frame ->
|
||||
frame.state = Frame.NORMAL
|
||||
frame.extendedState = Frame.NORMAL
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.awt.Window.restoreDesktopReaderFullscreenExitBounds(snapshot: DesktopReaderFullscreenSnapshot?) {
|
||||
if (snapshot == null) return
|
||||
runCatching {
|
||||
isAlwaysOnTop = snapshot.alwaysOnTop
|
||||
}
|
||||
if (!isVisible) {
|
||||
isVisible = true
|
||||
}
|
||||
val frame = this as? Frame
|
||||
if (frame == null) {
|
||||
bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||
return
|
||||
}
|
||||
val restoreMaximized = snapshot.frameState?.let { state ->
|
||||
state and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH
|
||||
} == true
|
||||
frame.extendedState = Frame.NORMAL
|
||||
frame.state = Frame.NORMAL
|
||||
frame.bounds = snapshot.frameBounds.desktopReaderRestoreBounds(snapshot.device)
|
||||
if (restoreMaximized) {
|
||||
frame.maximizedBounds = snapshot.device?.desktopReaderUsableBounds()
|
||||
EventQueue.invokeLater {
|
||||
if (frame.isDisplayable && frame.isShowing) {
|
||||
frame.extendedState = Frame.MAXIMIZED_BOTH
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.toFront()
|
||||
frame.requestFocus()
|
||||
frame.validate()
|
||||
}
|
||||
|
||||
private fun GraphicsDevice.desktopReaderScreenBounds(): Rectangle {
|
||||
return defaultConfiguration.bounds.desktopReaderCopy()
|
||||
}
|
||||
|
||||
private fun GraphicsDevice.desktopReaderUsableBounds(): Rectangle? {
|
||||
val configuration = defaultConfiguration ?: return null
|
||||
return runCatching {
|
||||
val bounds = configuration.bounds
|
||||
val insets = Toolkit.getDefaultToolkit().getScreenInsets(configuration)
|
||||
Rectangle(
|
||||
bounds.x + insets.left,
|
||||
bounds.y + insets.top,
|
||||
(bounds.width - insets.left - insets.right).coerceAtLeast(1),
|
||||
(bounds.height - insets.top - insets.bottom).coerceAtLeast(1)
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun Rectangle?.desktopReaderRestoreBounds(device: GraphicsDevice?): Rectangle {
|
||||
val usableBounds = device?.desktopReaderUsableBounds()
|
||||
?: return this?.desktopReaderCopy() ?: Rectangle(80, 80, 1280, 820)
|
||||
val source = this ?: usableBounds
|
||||
val width = source.width.coerceIn(640, usableBounds.width.coerceAtLeast(640))
|
||||
val height = source.height.coerceIn(480, usableBounds.height.coerceAtLeast(480))
|
||||
val looksFullscreen = source.x <= usableBounds.x &&
|
||||
source.y <= usableBounds.y &&
|
||||
source.width >= usableBounds.width &&
|
||||
source.height >= usableBounds.height
|
||||
if (looksFullscreen) {
|
||||
return usableBounds.desktopReaderCopy()
|
||||
}
|
||||
val maxX = (usableBounds.x + usableBounds.width - width).coerceAtLeast(usableBounds.x)
|
||||
val maxY = (usableBounds.y + usableBounds.height - height).coerceAtLeast(usableBounds.y)
|
||||
return Rectangle(
|
||||
source.x.coerceIn(usableBounds.x, maxX),
|
||||
source.y.coerceIn(usableBounds.y, maxY),
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
private fun Rectangle.desktopReaderCopy(): Rectangle {
|
||||
return Rectangle(x, y, width, height)
|
||||
}
|
||||
|
||||
private fun java.awt.Window.refreshDesktopReaderWindowFocus() {
|
||||
if (!isDisplayable) return
|
||||
if (this is Frame && extendedState and Frame.ICONIFIED != 0) {
|
||||
extendedState = extendedState and Frame.ICONIFIED.inv()
|
||||
}
|
||||
toFront()
|
||||
requestFocus()
|
||||
requestFocusInWindow()
|
||||
focusOwner?.requestFocus()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderFullscreenKeyEffect(
|
||||
enabled: Boolean,
|
||||
onKeyPressed: (AwtKeyEvent) -> Boolean
|
||||
) {
|
||||
val currentOnKeyPressed by rememberUpdatedState(onKeyPressed)
|
||||
DisposableEffect(enabled) {
|
||||
if (!enabled) {
|
||||
onDispose {}
|
||||
} else {
|
||||
val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
|
||||
val dispatcher = java.awt.KeyEventDispatcher { event ->
|
||||
val modalWindowActive = focusManager.activeWindow?.isDesktopReaderModalWindow() == true
|
||||
!modalWindowActive && event.id == AwtKeyEvent.KEY_PRESSED && currentOnKeyPressed(event)
|
||||
}
|
||||
focusManager.addKeyEventDispatcher(dispatcher)
|
||||
onDispose {
|
||||
focusManager.removeKeyEventDispatcher(dispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.awt.Window.isDesktopReaderModalWindow(): Boolean {
|
||||
val windowTitle = when (this) {
|
||||
is java.awt.Dialog -> title
|
||||
is Frame -> title
|
||||
else -> ""
|
||||
}
|
||||
return name?.startsWith(DesktopReaderModalWindowNamePrefix) == true ||
|
||||
windowTitle.startsWith("Reader Panel") ||
|
||||
windowTitle.startsWith("Reader Popup")
|
||||
}
|
||||
|
||||
private const val DesktopReaderModalWindowNamePrefix = "shared-reader-modal:"
|
||||
|
||||
internal data class DesktopWebViewRuntimeState(
|
||||
val initialized: Boolean = false,
|
||||
val restartRequired: Boolean = false,
|
||||
val downloadProgress: Float = -1f,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
internal fun shouldRequestDesktopWebViewRuntime(readerSurface: ReaderFeatureSurface?): Boolean {
|
||||
return readerSurface == ReaderFeatureSurface.TEXT_READER
|
||||
}
|
||||
|
||||
internal fun shouldStartDesktopWebViewRuntime(
|
||||
requested: Boolean,
|
||||
state: DesktopWebViewRuntimeState
|
||||
): Boolean {
|
||||
return requested && !state.initialized && !state.restartRequired && state.errorMessage == null
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopWebViewRuntimeIndicator(
|
||||
state: DesktopWebViewRuntimeState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message = when {
|
||||
state.errorMessage != null -> "Embedded webview could not start: ${state.errorMessage}"
|
||||
state.restartRequired -> "Embedded webview installed. Restart Episteme to finish setup."
|
||||
state.downloadProgress >= 0f -> "Preparing bundled embedded webview ${state.downloadProgress.toInt()}%"
|
||||
else -> "Preparing embedded webview..."
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.errorMessage == null && !state.restartRequired) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
color = if (state.errorMessage == null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (state.downloadProgress in 0f..100f) {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.downloadProgress / 100f },
|
||||
modifier = Modifier.width(260.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.ui.SharedNativeReaderLinkClick
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.event.KeyEvent as AwtKeyEvent
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal data class DesktopReaderPosition(
|
||||
val pageIndex: Int,
|
||||
val locator: ReaderLocator?
|
||||
)
|
||||
|
||||
internal data class DesktopReaderHighlightClick(
|
||||
val highlightId: String
|
||||
)
|
||||
|
||||
internal data class DesktopEpubLinkClick(
|
||||
val href: String,
|
||||
val chapterIndex: Int?,
|
||||
val text: String? = null,
|
||||
val chapterId: String? = null,
|
||||
val chapterHref: String? = null,
|
||||
val source: String = "bridge"
|
||||
)
|
||||
|
||||
internal fun SharedNativeReaderLinkClick.toDesktopEpubLinkClick(): DesktopEpubLinkClick {
|
||||
return DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = chapterIndex,
|
||||
text = text,
|
||||
source = "native"
|
||||
)
|
||||
}
|
||||
|
||||
internal data class DesktopEpubHandledLink(
|
||||
val href: String,
|
||||
val handledAtMs: Long
|
||||
)
|
||||
|
||||
internal enum class DesktopReaderSelectionAction {
|
||||
DEFINE,
|
||||
SPEAK,
|
||||
SEARCH
|
||||
}
|
||||
|
||||
internal enum class DesktopReaderKeyNavigation {
|
||||
NEXT,
|
||||
PREVIOUS,
|
||||
FIRST,
|
||||
LAST,
|
||||
SEARCH,
|
||||
NEXT_SEARCH,
|
||||
EXIT_FULLSCREEN
|
||||
}
|
||||
|
||||
internal fun AwtKeyEvent.desktopReaderKeyNavigationOrNull(fullscreen: Boolean): DesktopReaderKeyNavigation? {
|
||||
if (id != AwtKeyEvent.KEY_PRESSED) return null
|
||||
if (fullscreen && keyCode == AwtKeyEvent.VK_ESCAPE) {
|
||||
return DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||
}
|
||||
if (isControlDown && keyCode == AwtKeyEvent.VK_F) {
|
||||
return DesktopReaderKeyNavigation.SEARCH
|
||||
}
|
||||
if (isControlDown && keyCode == AwtKeyEvent.VK_G) {
|
||||
return DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
}
|
||||
return when (keyCode) {
|
||||
AwtKeyEvent.VK_RIGHT,
|
||||
AwtKeyEvent.VK_PAGE_DOWN -> DesktopReaderKeyNavigation.NEXT
|
||||
AwtKeyEvent.VK_LEFT,
|
||||
AwtKeyEvent.VK_PAGE_UP -> DesktopReaderKeyNavigation.PREVIOUS
|
||||
AwtKeyEvent.VK_HOME -> DesktopReaderKeyNavigation.FIRST
|
||||
AwtKeyEvent.VK_END -> DesktopReaderKeyNavigation.LAST
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal data class DesktopReaderSelectionActionPayload(
|
||||
val action: DesktopReaderSelectionAction,
|
||||
val text: String
|
||||
)
|
||||
|
||||
internal fun String.readerHighlightClickOrNull(): DesktopReaderHighlightClick? {
|
||||
fun parse(rawJson: String): DesktopReaderHighlightClick? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val highlightId = obj["id"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: obj["highlightId"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
DesktopReaderHighlightClick(highlightId)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerSelectionActionOrNull(): DesktopReaderSelectionActionPayload? {
|
||||
fun parse(rawJson: String): DesktopReaderSelectionActionPayload? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val text = obj["text"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
val action = when (
|
||||
obj["action"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.lowercase()
|
||||
) {
|
||||
"define" -> DesktopReaderSelectionAction.DEFINE
|
||||
"speak" -> DesktopReaderSelectionAction.SPEAK
|
||||
"web-search", "search" -> DesktopReaderSelectionAction.SEARCH
|
||||
else -> return@runCatching null
|
||||
}
|
||||
DesktopReaderSelectionActionPayload(action, text)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerSelectionDebugMessageOrNull(): String? {
|
||||
fun parse(rawJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["message"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerPaginationLogMessageOrNull(): String? {
|
||||
fun parse(rawJson: String): String? = runCatching {
|
||||
Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["message"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerPositionOrNull(): DesktopReaderPosition? {
|
||||
fun parse(rawJson: String): DesktopReaderPosition? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val pageIndex = obj["pageIndex"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.intOrNull
|
||||
?: return@runCatching null
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
pageIndex = pageIndex,
|
||||
startOffset = obj["startOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
endOffset = obj["endOffset"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
textQuote = obj["textQuote"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
cfi = obj["cfi"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||
)
|
||||
DesktopReaderPosition(pageIndex, locator)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerKeyNavigationOrNull(): DesktopReaderKeyNavigation? {
|
||||
fun parse(rawJson: String): DesktopReaderKeyNavigation? = runCatching {
|
||||
val action = Json.parseToJsonElement(rawJson)
|
||||
.jsonObject["action"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?: return@runCatching null
|
||||
when (action) {
|
||||
"next" -> DesktopReaderKeyNavigation.NEXT
|
||||
"previous" -> DesktopReaderKeyNavigation.PREVIOUS
|
||||
"first" -> DesktopReaderKeyNavigation.FIRST
|
||||
"last" -> DesktopReaderKeyNavigation.LAST
|
||||
"search" -> DesktopReaderKeyNavigation.SEARCH
|
||||
"nextSearch" -> DesktopReaderKeyNavigation.NEXT_SEARCH
|
||||
"exitFullscreen" -> DesktopReaderKeyNavigation.EXIT_FULLSCREEN
|
||||
else -> null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerLinkClickOrNull(): DesktopEpubLinkClick? {
|
||||
fun parse(rawJson: String): DesktopEpubLinkClick? = runCatching {
|
||||
val obj = Json.parseToJsonElement(rawJson).jsonObject
|
||||
val href = obj["href"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return@runCatching null
|
||||
DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = obj["chapterIndex"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull,
|
||||
text = obj["text"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
chapterId = obj["chapterId"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull,
|
||||
chapterHref = obj["chapterHref"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
parse(this)?.let { return it }
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(this).jsonPrimitive.contentOrNull
|
||||
}.getOrNull()?.let { parse(it) }
|
||||
}
|
||||
|
||||
internal fun String.readerLinkClickFromIntercept(): DesktopEpubLinkClick? {
|
||||
val trimmed = trim()
|
||||
if (trimmed.startsWith("readerlink:", ignoreCase = true)) {
|
||||
logEpubLink("request_intercept_readerlink raw=\"${trimmed.logPreview()}\"")
|
||||
val payload = trimmed.substringAfter("?", missingDelimiterValue = "")
|
||||
.split('&')
|
||||
.firstOrNull { it.substringBefore("=").equals("payload", ignoreCase = true) }
|
||||
?.substringAfter("=", missingDelimiterValue = "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (payload == null) {
|
||||
logEpubLink("request_intercept_readerlink_ignored reason=missing_payload")
|
||||
return null
|
||||
}
|
||||
val decoded = runCatching {
|
||||
URLDecoder.decode(payload, Charsets.UTF_8.name())
|
||||
}.getOrElse {
|
||||
logEpubLink("request_intercept_payload_decode_failed error=\"${it.message.orEmpty().logPreview()}\"")
|
||||
return null
|
||||
}
|
||||
val link = decoded.readerLinkClickOrNull()?.copy(source = "request")
|
||||
if (link == null) {
|
||||
logEpubLink("request_intercept_readerlink_ignored reason=parse_failed payload=\"${decoded.logPreview()}\"")
|
||||
}
|
||||
return link
|
||||
}
|
||||
return readerHrefFromIntercept()?.let { href ->
|
||||
DesktopEpubLinkClick(
|
||||
href = href,
|
||||
chapterIndex = null,
|
||||
source = "request"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.readerHrefFromIntercept(): String? {
|
||||
val trimmed = trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
if (trimmed.equals("about:blank", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:///kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file:/kcefbrowser/", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("file://", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("about:blank#", ignoreCase = true)) return "#${trimmed.substringAfter('#')}"
|
||||
if (trimmed.startsWith("data:", ignoreCase = true)) return null
|
||||
if (trimmed.startsWith("blob:", ignoreCase = true)) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
internal fun ReaderLocator.toReaderLocatorJson(): String {
|
||||
return buildString {
|
||||
append("{")
|
||||
val values = buildList {
|
||||
chapterIndex?.let { add("\"chapterIndex\":$it") }
|
||||
pageIndex?.let { add("\"pageIndex\":$it") }
|
||||
startOffset?.let { add("\"startOffset\":$it") }
|
||||
endOffset?.let { add("\"endOffset\":$it") }
|
||||
cfi?.let { add("\"cfi\":${it.toJsonStringLiteral()}") }
|
||||
textQuote?.let { add("\"textQuote\":${it.toJsonStringLiteral()}") }
|
||||
}
|
||||
append(values.joinToString(","))
|
||||
append("}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toJsonStringLiteral(): String {
|
||||
val builder = StringBuilder("\"")
|
||||
forEach { char ->
|
||||
when (char) {
|
||||
'\\' -> builder.append("\\\\")
|
||||
'"' -> builder.append("\\\"")
|
||||
'\n' -> builder.append("\\n")
|
||||
'\r' -> builder.append("\\r")
|
||||
'\t' -> builder.append("\\t")
|
||||
'\b' -> builder.append("\\b")
|
||||
'\u000C' -> builder.append("\\f")
|
||||
else -> {
|
||||
if (char.code < 0x20) {
|
||||
builder.append("\\u")
|
||||
builder.append(char.code.toString(16).padStart(4, '0'))
|
||||
} else {
|
||||
builder.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.append('"')
|
||||
return builder.toString()
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.reader.ReaderLayoutSignature
|
||||
import com.aryan.reader.shared.reader.ReaderViewportSpec
|
||||
import com.aryan.reader.shared.reader.SharedEpubBook
|
||||
|
||||
internal data class DesktopEpubPaginationRequest(
|
||||
val bookId: String,
|
||||
val chapterSignature: Int,
|
||||
val layoutSignature: ReaderLayoutSignature,
|
||||
val viewport: ReaderViewportSpec,
|
||||
val density: DesktopEpubPaginationDensity,
|
||||
val cacheGeneration: Int
|
||||
)
|
||||
|
||||
internal data class DesktopEpubPaginationDensity(
|
||||
val density: Float,
|
||||
val fontScale: Float
|
||||
)
|
||||
|
||||
internal fun SharedEpubBook.desktopPaginationContentSignature(): Int {
|
||||
return chapters.fold(31 * id.hashCode() + css.hashCode()) { acc, chapter ->
|
||||
31 * acc +
|
||||
chapter.id.hashCode() +
|
||||
chapter.plainText.length +
|
||||
chapter.plainText.hashCode() +
|
||||
chapter.semanticBlocks.hashCode() +
|
||||
chapter.htmlContent.length +
|
||||
chapter.htmlContent.hashCode() +
|
||||
chapter.baseHref.orEmpty().hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopEpubPaginationPreparing(
|
||||
active: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
if (active) "Preparing pages" else "Measuring reader layout",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
|
||||
internal const val EpistemeSourceUrl = "https://github.com/Aryan-Raj3112/episteme"
|
||||
internal const val EpistemeIssuesUrl = "https://github.com/Aryan-Raj3112/episteme/issues"
|
||||
internal const val EpistemeGitHubSponsorsUrl = "https://github.com/sponsors/Aryan-Raj3112"
|
||||
internal const val EpistemePatreonUrl = "https://www.patreon.com/c/epistemereader"
|
||||
internal const val EpistemeSupportEmail = "epistemereader@gmail.com"
|
||||
|
||||
private const val ExternalLinkLogTag = "EpistemeExternalLink"
|
||||
|
||||
internal fun desktopFeedbackSubject(profile: DesktopBuildProfile): String {
|
||||
return "Feedback: ${profile.appName}"
|
||||
}
|
||||
|
||||
internal fun desktopAppVersionName(): String {
|
||||
val version = System.getProperty(DesktopVersionProperty)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: EpistemeDesktopAppVersion::class.java.getPackage()?.implementationVersion
|
||||
?.takeIf { it.isNotBlank() }
|
||||
return version?.let { "Version $it" } ?: "Version unavailable"
|
||||
}
|
||||
|
||||
private object EpistemeDesktopAppVersion
|
||||
|
||||
@Composable
|
||||
internal fun DesktopExternalLinkDialog(
|
||||
url: String?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (url == null) return
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
LaunchedEffect(url) {
|
||||
logExternalLink("dialog_show url=\"${url.logPreview()}\"")
|
||||
}
|
||||
fun dismiss() {
|
||||
logExternalLink("dialog_dismiss url=\"${url.logPreview()}\"")
|
||||
onDismiss()
|
||||
}
|
||||
DesktopReaderBottomSheet(
|
||||
title = "External link",
|
||||
onDismiss = ::dismiss
|
||||
) {
|
||||
Text(
|
||||
"You clicked an external link.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Text(
|
||||
url,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = ::dismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
logExternalLink("dialog_copy url=\"${url.logPreview()}\"")
|
||||
clipboardManager.setText(AnnotatedString(url))
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
logExternalLink("dialog_open url=\"${url.logPreview()}\"")
|
||||
openExternalUrl(url)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun openExternalUrl(url: String) {
|
||||
if (!currentDesktopBuildProfile().featurePolicy.projectLinks) {
|
||||
logExternalLink("open_blocked_offline url=\"${url.logPreview()}\"")
|
||||
return
|
||||
}
|
||||
val normalizedUrl = url.normalizedExternalUrl()
|
||||
runCatching {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (normalizedUrl.startsWith("mailto:", ignoreCase = true)) {
|
||||
desktop.mail(URI(normalizedUrl))
|
||||
} else {
|
||||
desktop.browse(URI(normalizedUrl))
|
||||
}
|
||||
logExternalLink("open_system_browser_success url=\"${normalizedUrl.logPreview()}\"")
|
||||
} else {
|
||||
logExternalLink("open_system_browser_unavailable url=\"${normalizedUrl.logPreview()}\"")
|
||||
}
|
||||
}.onFailure { throwable ->
|
||||
logExternalLink("open_system_browser_failed url=\"${normalizedUrl.logPreview()}\" error=\"${throwable.message.orEmpty().logPreview()}\"")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.normalizedExternalUrl(): String {
|
||||
val trimmed = trim()
|
||||
return if (trimmed.startsWith("www.", ignoreCase = true)) {
|
||||
"https://$trimmed"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.isRemoteNetworkUrl(): Boolean {
|
||||
val trimmed = trim()
|
||||
return trimmed.startsWith("http://", ignoreCase = true) ||
|
||||
trimmed.startsWith("https://", ignoreCase = true) ||
|
||||
trimmed.startsWith("ws://", ignoreCase = true) ||
|
||||
trimmed.startsWith("wss://", ignoreCase = true)
|
||||
}
|
||||
|
||||
internal fun String.urlEncode(): String {
|
||||
return URLEncoder.encode(this, Charsets.UTF_8.name())
|
||||
}
|
||||
|
||||
private fun logExternalLink(message: String) {
|
||||
logDesktopDiagnostic(ExternalLinkLogTag) { message }
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ImportedBookFile
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
internal val DesktopReadableFileTypes = SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||
internal val DesktopSyncableFileTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||
internal val DesktopBookFileTypes = DesktopReadableFileTypes
|
||||
private val DesktopBookFileDialogPattern = SharedFileCapabilities.all
|
||||
.filter { it.type in DesktopBookFileTypes }
|
||||
.flatMap { capability -> capability.extensions.map { extension -> "*.$extension" } }
|
||||
.joinToString(";")
|
||||
|
||||
internal fun desktopBookFileTypesForDialog(): Set<FileType> = DesktopBookFileTypes
|
||||
|
||||
internal fun chooseFiles(): List<ImportedBookFile> {
|
||||
val dialog = FileDialog(null as Frame?, "Import books", FileDialog.LOAD).apply {
|
||||
isMultipleMode = true
|
||||
isVisible = true
|
||||
}
|
||||
return dialog.files.orEmpty().map { it.toDesktopImportedBookFile() }
|
||||
}
|
||||
|
||||
internal fun chooseBookFile(): File? {
|
||||
val dialog = FileDialog(null as Frame?, "Open Book", FileDialog.LOAD).apply {
|
||||
file = DesktopBookFileDialogPattern
|
||||
isVisible = true
|
||||
}
|
||||
val directory = dialog.directory ?: return null
|
||||
val file = dialog.file ?: return null
|
||||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun choosePdfFile(): File? {
|
||||
val dialog = FileDialog(null as Frame?, "Open PDF", FileDialog.LOAD).apply {
|
||||
file = "*.pdf"
|
||||
isVisible = true
|
||||
}
|
||||
val directory = dialog.directory ?: return null
|
||||
val file = dialog.file ?: return null
|
||||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun chooseFontFile(): File? {
|
||||
val dialog = FileDialog(null as Frame?, "Choose font", FileDialog.LOAD).apply {
|
||||
file = "*.ttf;*.otf;*.woff2"
|
||||
isVisible = true
|
||||
}
|
||||
val directory = dialog.directory ?: return null
|
||||
val file = dialog.file ?: return null
|
||||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun chooseReaderTextureFile(): File? {
|
||||
val dialog = FileDialog(null as Frame?, "Choose reader texture", FileDialog.LOAD).apply {
|
||||
file = "*.png;*.jpg;*.jpeg;*.webp;*.gif;*.bmp"
|
||||
isVisible = true
|
||||
}
|
||||
val directory = dialog.directory ?: return null
|
||||
val file = dialog.file ?: return null
|
||||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun chooseFolder(): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = "Import folder"
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
}
|
||||
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||
chooser.selectedFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ImportedBookFile.desktopFileType(): FileType {
|
||||
return SharedFileCapabilities.fileTypeForName(name)
|
||||
}
|
||||
|
||||
internal fun File.toDesktopImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
|
||||
return ImportedBookFile(
|
||||
name = name,
|
||||
uriString = null,
|
||||
localPath = absolutePath,
|
||||
size = length(),
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.aryan.reader.shared.ImportedBookFile
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.EventQueue
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTarget
|
||||
import java.awt.dnd.DropTargetAdapter
|
||||
import java.awt.dnd.DropTargetDragEvent
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.awt.dnd.DropTargetEvent
|
||||
import java.io.File
|
||||
|
||||
internal data class DesktopDropImportState(
|
||||
val active: Boolean = false,
|
||||
val supportedCount: Int = 0,
|
||||
val totalFileCount: Int = 0,
|
||||
val hasFilePayload: Boolean = false
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun DesktopFileDropTarget(
|
||||
window: Component?,
|
||||
onFilesDropped: (List<ImportedBookFile>) -> Unit,
|
||||
onDragStateChange: (DesktopDropImportState) -> Unit
|
||||
) {
|
||||
val onFilesDroppedState = rememberUpdatedState(onFilesDropped)
|
||||
val onDragStateChangeState = rememberUpdatedState(onDragStateChange)
|
||||
|
||||
DisposableEffect(window) {
|
||||
if (window == null) {
|
||||
onDispose { }
|
||||
} else {
|
||||
val installedTargets = mutableListOf<InstalledDropTarget>()
|
||||
var disposed = false
|
||||
var lastDragState = DesktopDropImportState()
|
||||
|
||||
fun publishDragState(state: DesktopDropImportState) {
|
||||
if (state == lastDragState) return
|
||||
lastDragState = state
|
||||
onDragStateChangeState.value(state)
|
||||
}
|
||||
|
||||
val listener = object : DropTargetAdapter() {
|
||||
override fun dragEnter(event: DropTargetDragEvent) {
|
||||
handleDrag(event)
|
||||
}
|
||||
|
||||
override fun dragOver(event: DropTargetDragEvent) {
|
||||
handleDrag(event)
|
||||
}
|
||||
|
||||
override fun dragExit(event: DropTargetEvent) {
|
||||
publishDragState(DesktopDropImportState())
|
||||
}
|
||||
|
||||
override fun drop(event: DropTargetDropEvent) {
|
||||
if (!event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||
event.rejectDrop()
|
||||
publishDragState(DesktopDropImportState())
|
||||
return
|
||||
}
|
||||
event.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val files = event.transferable.localDraggedFiles().filter { it.isFile }
|
||||
if (files.isEmpty()) {
|
||||
event.dropComplete(false)
|
||||
publishDragState(DesktopDropImportState())
|
||||
return
|
||||
}
|
||||
|
||||
onFilesDroppedState.value(files.map { it.toDesktopImportedBookFile() })
|
||||
event.dropComplete(true)
|
||||
publishDragState(DesktopDropImportState())
|
||||
}
|
||||
|
||||
private fun handleDrag(event: DropTargetDragEvent) {
|
||||
val hasFilePayload = event.isDataFlavorSupported(DataFlavor.javaFileListFlavor)
|
||||
publishDragState(
|
||||
DesktopDropImportState(
|
||||
active = true,
|
||||
hasFilePayload = hasFilePayload
|
||||
)
|
||||
)
|
||||
if (hasFilePayload) {
|
||||
event.acceptDrag(DnDConstants.ACTION_COPY)
|
||||
} else {
|
||||
event.rejectDrag()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.installDropTargets(listener, installedTargets)
|
||||
EventQueue.invokeLater {
|
||||
if (!disposed) {
|
||||
window.installDropTargets(listener, installedTargets)
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
disposed = true
|
||||
installedTargets.forEach { installed ->
|
||||
runCatching { installed.dropTarget.removeDropTargetListener(listener) }
|
||||
installed.component.dropTarget = installed.previous
|
||||
}
|
||||
publishDragState(DesktopDropImportState())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class InstalledDropTarget(
|
||||
val component: Component,
|
||||
val previous: DropTarget?,
|
||||
val dropTarget: DropTarget
|
||||
)
|
||||
|
||||
private fun Component.installDropTargets(
|
||||
listener: DropTargetAdapter,
|
||||
installedTargets: MutableList<InstalledDropTarget>
|
||||
) {
|
||||
collectDropTargetComponents()
|
||||
.distinct()
|
||||
.filterNot { component -> installedTargets.any { it.component == component } }
|
||||
.forEach { component ->
|
||||
val previous = component.dropTarget
|
||||
val target = DropTarget(component, DnDConstants.ACTION_COPY, listener, true)
|
||||
installedTargets += InstalledDropTarget(component, previous, target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Component.collectDropTargetComponents(): List<Component> {
|
||||
val collected = mutableListOf<Component>()
|
||||
|
||||
fun visit(component: Component) {
|
||||
collected += component
|
||||
if (component is Container) {
|
||||
component.components.forEach(::visit)
|
||||
}
|
||||
}
|
||||
|
||||
visit(this)
|
||||
return collected
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopDropImportOverlay(state: DesktopDropImportState) {
|
||||
if (!state.active) return
|
||||
|
||||
val hasSupportedFiles = state.supportedCount > 0
|
||||
val title = when {
|
||||
hasSupportedFiles -> "Drop to import ${state.supportedCount} file${if (state.supportedCount == 1) "" else "s"}"
|
||||
state.hasFilePayload -> "Drop supported files to import"
|
||||
else -> "Drop files to import"
|
||||
}
|
||||
val body = if (hasSupportedFiles) {
|
||||
val skipped = state.totalFileCount - state.supportedCount
|
||||
if (skipped > 0) {
|
||||
"$skipped unsupported file${if (skipped == 1) "" else "s"} will be skipped."
|
||||
} else {
|
||||
"Release to add to your library."
|
||||
}
|
||||
} else {
|
||||
SharedFileCapabilities.supportedFormatsLabel(ReaderPlatform.DESKTOP)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(20f)
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.36f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 30.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.awt.datatransfer.Transferable.localDraggedFiles(): List<File> {
|
||||
if (!isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return emptyList()
|
||||
return runCatching {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(getTransferData(DataFlavor.javaFileListFlavor) as? List<*>)
|
||||
.orEmpty()
|
||||
.filterIsInstance<File>()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
|
@ -0,0 +1,506 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.AppAction
|
||||
import com.aryan.reader.shared.BannerMessage
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf
|
||||
import com.aryan.reader.shared.SmartCollectionDefinition
|
||||
import com.aryan.reader.shared.SmartField
|
||||
import com.aryan.reader.shared.SmartOperator
|
||||
import com.aryan.reader.shared.SmartRule
|
||||
import com.aryan.reader.shared.Tag
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.reduce
|
||||
import com.aryan.reader.shared.ui.NonReaderLibraryTab
|
||||
import com.aryan.reader.shared.ui.SharedHomeScreen
|
||||
import com.aryan.reader.shared.ui.SharedLibraryScreen
|
||||
import com.aryan.reader.shared.ui.SharedShelvesScreen
|
||||
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||
import java.io.File
|
||||
|
||||
internal fun BookItem.hasEmbeddedMetadataChange(updated: BookItem): Boolean {
|
||||
return title != updated.title ||
|
||||
author != updated.author ||
|
||||
description != updated.description ||
|
||||
seriesName != updated.seriesName ||
|
||||
seriesIndex != updated.seriesIndex
|
||||
}
|
||||
|
||||
internal fun String.toDesktopSafeFileName(): String {
|
||||
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||
}
|
||||
|
||||
internal fun BookItem.withDesktopImportMetadata(
|
||||
enriched: BookItem,
|
||||
original: BookItem?
|
||||
): BookItem {
|
||||
fun shouldApplyText(current: String?, originalValue: String?): Boolean {
|
||||
return current.isNullOrBlank() || current == originalValue
|
||||
}
|
||||
|
||||
return copy(
|
||||
title = if (shouldApplyText(title, original?.title)) {
|
||||
enriched.title ?: title
|
||||
} else {
|
||||
title
|
||||
},
|
||||
author = if (shouldApplyText(author, original?.author)) {
|
||||
enriched.author ?: author
|
||||
} else {
|
||||
author
|
||||
},
|
||||
description = if (shouldApplyText(description, original?.description)) {
|
||||
enriched.description ?: description
|
||||
} else {
|
||||
description
|
||||
},
|
||||
seriesName = if (shouldApplyText(seriesName, original?.seriesName)) {
|
||||
enriched.seriesName ?: seriesName
|
||||
} else {
|
||||
seriesName
|
||||
},
|
||||
seriesIndex = if (seriesIndex == null || seriesIndex == original?.seriesIndex) {
|
||||
enriched.seriesIndex ?: seriesIndex
|
||||
} else {
|
||||
seriesIndex
|
||||
},
|
||||
originalTitle = originalTitle ?: enriched.originalTitle ?: enriched.title,
|
||||
originalAuthor = originalAuthor ?: enriched.originalAuthor ?: enriched.author,
|
||||
originalSeriesName = originalSeriesName ?: enriched.originalSeriesName ?: enriched.seriesName,
|
||||
originalSeriesIndex = originalSeriesIndex ?: enriched.originalSeriesIndex ?: enriched.seriesIndex,
|
||||
originalDescription = originalDescription ?: enriched.originalDescription ?: enriched.description,
|
||||
fileSize = enriched.fileSize.takeIf { it > 0L } ?: fileSize,
|
||||
fileContentModifiedTimestamp = enriched.fileContentModifiedTimestamp.takeIf { it > 0L }
|
||||
?: fileContentModifiedTimestamp,
|
||||
coverImagePath = coverImagePath?.takeIf { File(it).isFile } ?: enriched.coverImagePath,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed || enriched.folderTextMetadataParsed
|
||||
)
|
||||
}
|
||||
|
||||
internal fun resolvedDesktopReaderSettings(
|
||||
book: BookItem,
|
||||
readerDefaultSettings: ReaderSettings
|
||||
): ReaderSettings {
|
||||
return book.readerSettings ?: readerDefaultSettings
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderOpeningScreen(
|
||||
opening: DesktopReaderOpening,
|
||||
onReturnToLibrary: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = "Opening ${opening.title}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = opening.formatLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
TextButton(onClick = onReturnToLibrary) {
|
||||
Text("Return to library")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeScreen(
|
||||
state: SharedReaderScreenState,
|
||||
onImportBooks: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onRead: (BookItem) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onRemoveSelected: () -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTagSelectedBooks: () -> Unit,
|
||||
onAddSelectedBooksToShelf: () -> Unit,
|
||||
onOpenTab: (BookItem) -> Unit,
|
||||
onCloseTab: (BookItem) -> Unit,
|
||||
onCloseAllTabs: () -> Unit,
|
||||
onRecentLimitChange: (Int) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
SharedHomeScreen(
|
||||
state = state,
|
||||
onImportBooks = onImportBooks,
|
||||
onImportFolder = onImportFolder,
|
||||
onOpenBook = onRead,
|
||||
onToggleSelection = onSelect,
|
||||
onClearSelection = onClearSelection,
|
||||
onRemoveSelected = onRemoveSelected,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTagSelectedBooks = onTagSelectedBooks,
|
||||
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||
onOpenTab = onOpenTab,
|
||||
onCloseTab = onCloseTab,
|
||||
onCloseAllTabs = onCloseAllTabs,
|
||||
onRecentLimitChange = onRecentLimitChange,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onOpenSettings = onOpenSettings,
|
||||
showActiveTabs = false
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryScreen(
|
||||
state: SharedReaderScreenState,
|
||||
selectedLibraryTab: NonReaderLibraryTab,
|
||||
onLibraryTabChange: (NonReaderLibraryTab) -> Unit,
|
||||
onStateChange: (SharedReaderScreenState) -> Unit,
|
||||
onImportBooks: () -> Unit,
|
||||
onRead: (BookItem) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onRemoveSelected: () -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onCreateSmartShelf: () -> Unit,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit,
|
||||
onTagSelectedBooks: () -> Unit,
|
||||
onAddSelectedBooksToShelf: () -> Unit,
|
||||
onImportFolder: () -> Unit,
|
||||
onSyncFolderMetadata: () -> Unit,
|
||||
onScanFolders: () -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit
|
||||
) {
|
||||
SharedLibraryScreen(
|
||||
state = state,
|
||||
selectedTab = selectedLibraryTab,
|
||||
onTabChange = onLibraryTabChange,
|
||||
onStateChange = onStateChange,
|
||||
onImportBooks = onImportBooks,
|
||||
onOpenBook = onRead,
|
||||
onToggleSelection = onSelect,
|
||||
onClearSelection = onClearSelection,
|
||||
onRemoveSelected = onRemoveSelected,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onCreateSmartShelf = onCreateSmartShelf,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder,
|
||||
onTagSelectedBooks = onTagSelectedBooks,
|
||||
onAddSelectedBooksToShelf = onAddSelectedBooksToShelf,
|
||||
onImportFolder = onImportFolder,
|
||||
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||
onScanFolders = onScanFolders,
|
||||
onTogglePinned = onTogglePinned,
|
||||
useImportEmptyStateWhenLibraryEmpty = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ShelvesScreen(
|
||||
shelves: List<Shelf>,
|
||||
selectedBookIds: Set<String>,
|
||||
pinnedBookIds: Set<String>,
|
||||
onRead: (BookItem) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onShowBookInfo: (BookItem) -> Unit,
|
||||
onEditBook: (BookItem) -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onCreateShelf: () -> Unit,
|
||||
onCreateSmartShelf: () -> Unit,
|
||||
onRenameShelf: (Shelf) -> Unit,
|
||||
onDeleteShelf: (Shelf) -> Unit,
|
||||
onRemoveFolder: (Shelf) -> Unit
|
||||
) {
|
||||
SharedShelvesScreen(
|
||||
shelves = shelves,
|
||||
selectedBookIds = selectedBookIds,
|
||||
pinnedBookIds = pinnedBookIds,
|
||||
onOpenBook = onRead,
|
||||
onToggleSelection = onSelect,
|
||||
onShowBookInfo = onShowBookInfo,
|
||||
onEditBook = onEditBook,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onCreateShelf = onCreateShelf,
|
||||
onCreateSmartShelf = onCreateSmartShelf,
|
||||
onRenameShelf = onRenameShelf,
|
||||
onDeleteShelf = onDeleteShelf,
|
||||
onRemoveFolder = onRemoveFolder
|
||||
)
|
||||
}
|
||||
|
||||
private data class DesktopSmartRuleDraft(
|
||||
val field: SmartField = SmartField.TITLE,
|
||||
val operator: SmartOperator = SmartOperator.CONTAINS,
|
||||
val value: String = ""
|
||||
) {
|
||||
fun toRule(): SmartRule? {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
return SmartRule(field = field, operator = operator, value = trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SmartShelfDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, SmartCollectionDefinition) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var matchAll by remember { mutableStateOf(true) }
|
||||
var rules by remember { mutableStateOf(listOf(DesktopSmartRuleDraft())) }
|
||||
val validRules = rules.mapNotNull { it.toRule() }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Create smart shelf") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Shelf name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FilterChip(
|
||||
selected = matchAll,
|
||||
onClick = { matchAll = true },
|
||||
label = { Text("All") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = !matchAll,
|
||||
onClick = { matchAll = false },
|
||||
label = { Text("Any") }
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = { rules = rules + DesktopSmartRuleDraft() },
|
||||
enabled = rules.size < 4
|
||||
) {
|
||||
Text("Add rule")
|
||||
}
|
||||
}
|
||||
rules.forEachIndexed { index, draft ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SmartRuleDropdown(
|
||||
label = "Field",
|
||||
selected = draft.field,
|
||||
options = SmartField.entries.toList(),
|
||||
optionLabel = { it.desktopLabel() },
|
||||
onSelected = { field ->
|
||||
rules = rules.updateAt(index) {
|
||||
val operator = smartOperatorsFor(field).first()
|
||||
copy(field = field, operator = operator, value = "")
|
||||
}
|
||||
}
|
||||
)
|
||||
SmartRuleDropdown(
|
||||
label = "Operator",
|
||||
selected = draft.operator,
|
||||
options = smartOperatorsFor(draft.field),
|
||||
optionLabel = { it.desktopLabel() },
|
||||
onSelected = { operator ->
|
||||
rules = rules.updateAt(index) { copy(operator = operator) }
|
||||
}
|
||||
)
|
||||
if (rules.size > 1) {
|
||||
TextButton(onClick = { rules = rules.filterIndexed { i, _ -> i != index } }) {
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { value -> rules = rules.updateAt(index) { copy(value = value) } },
|
||||
label = { Text(draft.field.valueLabel()) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectionKey = index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(name, SmartCollectionDefinition(matchAll = matchAll, rules = validRules))
|
||||
},
|
||||
enabled = name.isNotBlank() && validRules.isNotEmpty()
|
||||
) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> SmartRuleDropdown(
|
||||
label: String,
|
||||
selected: T,
|
||||
options: List<T>,
|
||||
optionLabel: (T) -> String,
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text("$label: ${optionLabel(selected)}")
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(optionLabel(option)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelected(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun smartOperatorsFor(field: SmartField): List<SmartOperator> {
|
||||
return when (field) {
|
||||
SmartField.PROGRESS -> listOf(SmartOperator.GREATER_THAN, SmartOperator.LESS_THAN, SmartOperator.EQUALS)
|
||||
else -> listOf(SmartOperator.CONTAINS, SmartOperator.EQUALS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SmartField.desktopLabel(): String {
|
||||
return when (this) {
|
||||
SmartField.TITLE -> "Title"
|
||||
SmartField.AUTHOR -> "Author"
|
||||
SmartField.PROGRESS -> "Progress"
|
||||
SmartField.FILE_TYPE -> "File type"
|
||||
SmartField.FOLDER -> "Folder"
|
||||
SmartField.TAG -> "Tag"
|
||||
}
|
||||
}
|
||||
|
||||
private fun SmartField.valueLabel(): String {
|
||||
return when (this) {
|
||||
SmartField.PROGRESS -> "Percent"
|
||||
SmartField.FILE_TYPE -> "Type, e.g. PDF"
|
||||
SmartField.FOLDER -> "Folder path"
|
||||
SmartField.TAG -> "Tag name"
|
||||
SmartField.TITLE -> "Title text"
|
||||
SmartField.AUTHOR -> "Author text"
|
||||
}
|
||||
}
|
||||
|
||||
private fun SmartOperator.desktopLabel(): String {
|
||||
return when (this) {
|
||||
SmartOperator.EQUALS -> "Equals"
|
||||
SmartOperator.CONTAINS -> "Contains"
|
||||
SmartOperator.GREATER_THAN -> "Greater than"
|
||||
SmartOperator.LESS_THAN -> "Less than"
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun List<DesktopSmartRuleDraft>.updateAt(
|
||||
index: Int,
|
||||
transform: DesktopSmartRuleDraft.() -> DesktopSmartRuleDraft
|
||||
): List<DesktopSmartRuleDraft> {
|
||||
return mapIndexed { i, draft -> if (i == index) draft.transform() else draft }
|
||||
}
|
||||
|
||||
internal fun SharedReaderScreenState.withBanner(message: String, isError: Boolean = false): SharedReaderScreenState {
|
||||
return reduce(AppAction.BannerShown(BannerMessage(message, isError = isError)))
|
||||
}
|
||||
|
||||
internal object DesktopFolderPathResolver : SharedFolderPathResolver {
|
||||
override fun relativeFolderSegments(item: BookItem): List<String> {
|
||||
val sourceFolder = item.sourceFolder ?: return emptyList()
|
||||
val bookPath = item.path ?: return emptyList()
|
||||
val parentFile = File(bookPath).parentFile ?: return emptyList()
|
||||
val paths = runCatching {
|
||||
File(sourceFolder).toPath().toAbsolutePath().normalize() to
|
||||
parentFile.toPath().toAbsolutePath().normalize()
|
||||
}.getOrNull() ?: return emptyList()
|
||||
val (root, parent) = paths
|
||||
if (!parent.startsWith(root) || parent == root) return emptyList()
|
||||
return root.relativize(parent).map { it.toString() }.filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<BookItem>.collectTags(): List<Tag> {
|
||||
return flatMap { it.tags }.distinctBy { it.id }.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
internal fun BookItem.cardTitleForMessage(): String {
|
||||
return title?.takeIf { it.isNotBlank() } ?: displayName
|
||||
}
|
||||
|
||||
internal fun Long.toReadableSize(): String {
|
||||
if (this <= 0L) return "Unknown"
|
||||
val units = listOf("B", "KB", "MB", "GB", "TB")
|
||||
var value = this.toDouble()
|
||||
var unitIndex = 0
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
value /= 1024.0
|
||||
unitIndex += 1
|
||||
}
|
||||
return if (unitIndex == 0) {
|
||||
"$this ${units[unitIndex]}"
|
||||
} else {
|
||||
"${String.format("%.1f", value)} ${units[unitIndex]}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
internal fun String.logPreview(maxLength: Int = 96): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import com.aryan.reader.shared.pdf.SharedPdfEmbeddedAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokePercent
|
||||
import com.aryan.reader.shared.pdf.sharedPdfStrokeWidthRange
|
||||
import com.aryan.reader.shared.pdf.sharedPdfTextStyle
|
||||
import com.aryan.reader.shared.pdf.withSharedPdfTextStyle
|
||||
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
|
||||
import com.aryan.reader.shared.ui.SharedPdfTextStyleControls
|
||||
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfAnnotationEditor(
|
||||
annotation: SharedPdfAnnotation,
|
||||
onUpdate: (SharedPdfAnnotation) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
showSearch: Boolean,
|
||||
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit = {},
|
||||
onSearch: () -> Unit
|
||||
) {
|
||||
val highlighterColors = remember(highlighterPalette) {
|
||||
SharedPdfAndroidHighlightColors.palette
|
||||
}
|
||||
var editingHighlighterSlot by remember(annotation.id, highlighterColors) { mutableStateOf<Int?>(null) }
|
||||
val isHighlighterAnnotation = annotation.kind == PdfAnnotationKind.HIGHLIGHT ||
|
||||
annotation.tool == PdfInkTool.HIGHLIGHTER ||
|
||||
annotation.tool == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Selected ${annotation.desktopLabel()}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClose) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Page ${annotation.pageIndex + 1}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
if (annotation.text.isNotBlank()) {
|
||||
Surface(
|
||||
color = Color(annotation.colorArgb).copy(alpha = 0.10f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, Color(annotation.colorArgb).copy(alpha = 0.28f)),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.heightIn(min = 72.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(6.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(annotation.colorArgb))
|
||||
)
|
||||
Text(
|
||||
"\"${annotation.text}\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f),
|
||||
modifier = Modifier.padding(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DesktopBottomSheetToolButton(
|
||||
icon = Icons.Default.ContentCopy,
|
||||
label = "Copy",
|
||||
onClick = onCopy
|
||||
)
|
||||
if (showSearch) {
|
||||
DesktopBottomSheetToolButton(
|
||||
icon = Icons.Default.Search,
|
||||
label = "Search",
|
||||
onClick = onSearch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (annotation.kind == PdfAnnotationKind.TEXT) {
|
||||
SharedStableOutlinedTextField(
|
||||
value = annotation.text,
|
||||
onValueChange = { onUpdate(annotation.copy(text = it)) },
|
||||
label = { Text("Text note") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectionKey = annotation.id
|
||||
)
|
||||
SharedPdfTextStyleControls(
|
||||
style = annotation.sharedPdfTextStyle(),
|
||||
onStyleChange = { onUpdate(annotation.withSharedPdfTextStyle(it)) }
|
||||
)
|
||||
}
|
||||
if (annotation.kind != PdfAnnotationKind.TEXT) {
|
||||
val palette = if (isHighlighterAnnotation) {
|
||||
highlighterColors
|
||||
} else {
|
||||
SharedPdfAnnotationDefaults.penPalette
|
||||
}
|
||||
Text("Color", style = MaterialTheme.typography.labelLarge)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
palette.forEachIndexed { _, argb ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.clickable {
|
||||
val nextColor = if (isHighlighterAnnotation) {
|
||||
SharedPdfAndroidHighlightColors.nearestArgb(argb)
|
||||
} else {
|
||||
argb
|
||||
}
|
||||
onUpdate(annotation.copy(colorArgb = nextColor))
|
||||
},
|
||||
color = Color(argb),
|
||||
shape = RoundedCornerShape(13.dp),
|
||||
content = {}
|
||||
)
|
||||
}
|
||||
if (isHighlighterAnnotation) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(RoundedCornerShape(15.dp))
|
||||
.background(
|
||||
Brush.sweepGradient(
|
||||
listOf(
|
||||
Color.Red,
|
||||
Color.Yellow,
|
||||
Color.Green,
|
||||
Color.Cyan,
|
||||
Color.Blue,
|
||||
Color.Magenta,
|
||||
Color.Red
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(15.dp))
|
||||
.clickable {
|
||||
editingHighlighterSlot = highlighterColors
|
||||
.indexOf(annotation.colorArgb)
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = annotation.note.orEmpty(),
|
||||
onValueChange = { note -> onUpdate(annotation.copy(note = note.takeIf { it.isNotBlank() })) },
|
||||
label = { Text("Note") },
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
selectionKey = annotation.id
|
||||
)
|
||||
}
|
||||
if (annotation.kind == PdfAnnotationKind.INK) {
|
||||
val strokeRange = annotation.tool.sharedPdfStrokeWidthRange()
|
||||
val strokeValue = annotation.strokeWidth.coerceIn(strokeRange.start, strokeRange.endInclusive)
|
||||
Text("Thickness ${strokeValue.sharedPdfStrokePercent(strokeRange)}", style = MaterialTheme.typography.labelLarge)
|
||||
Slider(
|
||||
value = strokeValue,
|
||||
onValueChange = { onUpdate(annotation.copy(strokeWidth = it.coerceAtLeast(0.0001f))) },
|
||||
valueRange = strokeRange
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onDelete) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
editingHighlighterSlot?.let { requestedSlot ->
|
||||
val slot = requestedSlot.coerceIn(0, highlighterColors.lastIndex)
|
||||
val initialColor = Color(highlighterColors[slot]).copy(alpha = 1f)
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = "Highlight color ${slot + 1}",
|
||||
onDismiss = { editingHighlighterSlot = null },
|
||||
onSave = { color ->
|
||||
val nextArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
val syncedArgb = SharedPdfAndroidHighlightColors.nearestArgb(nextArgb)
|
||||
onHighlighterPaletteChange(
|
||||
SharedPdfHighlighterPalette(highlighterColors).withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = nextArgb
|
||||
)
|
||||
)
|
||||
onUpdate(annotation.copy(colorArgb = syncedArgb))
|
||||
editingHighlighterSlot = null
|
||||
}
|
||||
) { liveColor ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
highlighterColors.forEachIndexed { index, argb ->
|
||||
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(color)
|
||||
.border(
|
||||
width = if (index == slot) 3.dp else 1.dp,
|
||||
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(21.dp)
|
||||
)
|
||||
.clickable { editingHighlighterSlot = index },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = if (color.luminance() > 0.5f) Color.Black else Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopBottomSheetToolButton(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfEmbeddedAnnotationPanel(
|
||||
annotation: SharedPdfEmbeddedAnnotation,
|
||||
onCopy: () -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Embedded PDF comment",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = onClose) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Page ${annotation.pageIndex + 1}${annotation.author.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
DesktopPdfEmbeddedComment(
|
||||
author = annotation.author,
|
||||
contents = annotation.contents.ifBlank { "No comment" },
|
||||
depth = 0
|
||||
)
|
||||
DesktopPdfEmbeddedReplies(annotation.replies, depth = 1)
|
||||
TextButton(onClick = onCopy) {
|
||||
Text("Copy thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfEmbeddedReplies(
|
||||
replies: List<SharedPdfEmbeddedAnnotation>,
|
||||
depth: Int
|
||||
) {
|
||||
replies.forEach { reply ->
|
||||
HorizontalDivider()
|
||||
DesktopPdfEmbeddedComment(
|
||||
author = reply.author,
|
||||
contents = reply.contents,
|
||||
depth = depth
|
||||
)
|
||||
if (reply.replies.isNotEmpty()) {
|
||||
DesktopPdfEmbeddedReplies(reply.replies, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfEmbeddedComment(
|
||||
author: String,
|
||||
contents: String,
|
||||
depth: Int
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = (depth * 12).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||
) {
|
||||
Text(
|
||||
author.ifBlank { "Unknown" },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
contents.ifBlank { "No comment" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPdfAnnotation.desktopLabel(): String {
|
||||
return when (kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT -> "highlight"
|
||||
PdfAnnotationKind.INK -> tool.name.lowercase().replace('_', ' ')
|
||||
PdfAnnotationKind.TEXT -> "text note"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPdfAnnotation.desktopSheetTitle(): String {
|
||||
return when (kind) {
|
||||
PdfAnnotationKind.HIGHLIGHT -> "Highlight"
|
||||
PdfAnnotationKind.INK -> "Annotation"
|
||||
PdfAnnotationKind.TEXT -> "Text note"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPdfEmbeddedAnnotation.threadText(): String {
|
||||
return buildString {
|
||||
append(author.ifBlank { "Unknown" })
|
||||
append(": ")
|
||||
appendLine(contents.ifBlank { "No comment" })
|
||||
fun appendReplies(replies: List<SharedPdfEmbeddedAnnotation>, indent: String) {
|
||||
replies.forEach { reply ->
|
||||
append(indent)
|
||||
append(reply.author.ifBlank { "Unknown" })
|
||||
append(": ")
|
||||
appendLine(reply.contents.ifBlank { "No comment" })
|
||||
appendReplies(reply.replies, "$indent ")
|
||||
}
|
||||
}
|
||||
appendReplies(replies, " ")
|
||||
}.trimEnd()
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.BuiltInPdfReaderThemes
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
|
||||
internal enum class DesktopPdfInspectorTab(val title: String) {
|
||||
VIEW("View"),
|
||||
MARKUP("Markup"),
|
||||
ASSIST("Assist")
|
||||
}
|
||||
|
||||
internal data class DesktopPdfThemeStyle(
|
||||
val theme: ReaderTheme,
|
||||
val viewerBackgroundColor: Color,
|
||||
val pageBackgroundColor: Color,
|
||||
val colorFilter: ColorFilter?,
|
||||
val textureBitmap: ImageBitmap?,
|
||||
val textureAlpha: Float,
|
||||
val textureBlendMode: BlendMode
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfThemedPageImage(
|
||||
bitmap: ImageBitmap,
|
||||
contentDescription: String,
|
||||
themeStyle: DesktopPdfThemeStyle,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier.background(themeStyle.pageBackgroundColor)) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = themeStyle.colorFilter,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
val textureBitmap = themeStyle.textureBitmap
|
||||
if (textureBitmap != null && themeStyle.textureAlpha > 0f) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawRect(
|
||||
brush = ShaderBrush(ImageShader(textureBitmap, TileMode.Repeated, TileMode.Repeated)),
|
||||
size = size,
|
||||
blendMode = themeStyle.textureBlendMode,
|
||||
alpha = themeStyle.textureAlpha
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ReaderSettings?.toDesktopPdfReaderSettings(): ReaderSettings {
|
||||
val defaults = ReaderSettings(themeId = "no_theme")
|
||||
val settings = this ?: defaults
|
||||
val themeId = settings.themeId
|
||||
val hasPdfTheme = BuiltInPdfReaderThemes.any { it.id == themeId }
|
||||
val hasCustomColors = settings.backgroundColorArgb != null && settings.textColorArgb != null
|
||||
return settings.copy(
|
||||
themeId = when {
|
||||
themeId == null -> "no_theme"
|
||||
hasPdfTheme || hasCustomColors -> themeId
|
||||
else -> "no_theme"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal fun ReaderSettings.toDesktopPdfThemeStyle(displayMode: PdfDisplayMode): DesktopPdfThemeStyle {
|
||||
val theme = toDesktopPdfTheme()
|
||||
val pageBackground = desktopPdfPageBackgroundColor(theme, displayMode)
|
||||
val isDarkTexture = theme.isDark || theme.id == "reverse"
|
||||
return DesktopPdfThemeStyle(
|
||||
theme = theme,
|
||||
viewerBackgroundColor = pageBackground,
|
||||
pageBackgroundColor = pageBackground,
|
||||
colorFilter = theme.toDesktopPdfColorFilter(),
|
||||
textureBitmap = DesktopReaderTextures.imageBitmapFor(textureId),
|
||||
textureAlpha = if (textureId == null) 0f else textureAlpha.coerceIn(0f, 1f),
|
||||
textureBlendMode = if (isDarkTexture) BlendMode.Screen else BlendMode.Multiply
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReaderSettings.toDesktopPdfTheme(): ReaderTheme {
|
||||
BuiltInPdfReaderThemes.firstOrNull { it.id == themeId }?.let { return it }
|
||||
val background = backgroundColorArgb?.toComposeColor()
|
||||
val text = textColorArgb?.toComposeColor()
|
||||
return if (background != null && text != null) {
|
||||
ReaderTheme(
|
||||
id = themeId ?: "desktop_pdf_custom",
|
||||
name = "Custom",
|
||||
backgroundColor = background,
|
||||
textColor = text,
|
||||
isDark = darkMode,
|
||||
textureId = textureId,
|
||||
isCustom = true
|
||||
)
|
||||
} else {
|
||||
BuiltInPdfReaderThemes.first()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ReaderTheme.toDesktopPdfColorFilter(): ColorFilter? {
|
||||
return when (id) {
|
||||
"no_theme", "system" -> null
|
||||
"reverse" -> {
|
||||
val colorMatrix = floatArrayOf(
|
||||
-1f, 0f, 0f, 0f, 255f,
|
||||
0f, -1f, 0f, 0f, 255f,
|
||||
0f, 0f, -1f, 0f, 255f,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
)
|
||||
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
||||
}
|
||||
else -> {
|
||||
if (!backgroundColor.isSpecified || !textColor.isSpecified) return null
|
||||
val bgR = backgroundColor.red * 255f
|
||||
val bgG = backgroundColor.green * 255f
|
||||
val bgB = backgroundColor.blue * 255f
|
||||
val fgR = textColor.red * 255f
|
||||
val fgG = textColor.green * 255f
|
||||
val fgB = textColor.blue * 255f
|
||||
val dr = (bgR - fgR) / 255f
|
||||
val dg = (bgG - fgG) / 255f
|
||||
val db = (bgB - fgB) / 255f
|
||||
val lumR = 0.2126f
|
||||
val lumG = 0.7152f
|
||||
val lumB = 0.0722f
|
||||
val colorMatrix = floatArrayOf(
|
||||
dr * lumR, dr * lumG, dr * lumB, 0f, fgR,
|
||||
dg * lumR, dg * lumG, dg * lumB, 0f, fgG,
|
||||
db * lumR, db * lumG, db * lumB, 0f, fgB,
|
||||
0f, 0f, 0f, 1f, 0f
|
||||
)
|
||||
ColorFilter.colorMatrix(ColorMatrix(colorMatrix))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfInspectorSection(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfVisualOptionSwitch(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toComposeColor(): Color {
|
||||
return Color(this and 0xFFFFFFFFL)
|
||||
}
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.ZoomOut
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.aryan.reader.shared.SearchHighlightMode
|
||||
import com.aryan.reader.shared.pdf.SharedPdfSearchResult
|
||||
import com.aryan.reader.shared.ui.ReaderMinimalSlider
|
||||
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfFullscreenBottomChrome(
|
||||
pageIndex: Int,
|
||||
pageCount: Int,
|
||||
showJumpHistory: Boolean,
|
||||
jumpBackPage: Int?,
|
||||
jumpForwardPage: Int?,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPageScrub: (Float) -> Unit,
|
||||
onPageScrubFinished: () -> Unit,
|
||||
onJumpBack: () -> Unit,
|
||||
onJumpForward: () -> Unit,
|
||||
onClearJumpHistory: () -> Unit
|
||||
) {
|
||||
val chromeBackground = MaterialTheme.colorScheme.surface
|
||||
val chromeContent = MaterialTheme.colorScheme.onSurface
|
||||
val sliderActive = MaterialTheme.colorScheme.primary
|
||||
val sliderInactive = MaterialTheme.colorScheme.surfaceVariant
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 6.dp, end = 16.dp, bottom = 0.dp),
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp),
|
||||
color = chromeBackground,
|
||||
contentColor = chromeContent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 1.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null
|
||||
DesktopPdfJumpHistoryControls(
|
||||
visible = showJumpHistory,
|
||||
backPage = jumpBackPage,
|
||||
forwardPage = jumpForwardPage,
|
||||
onBack = onJumpBack,
|
||||
onForward = onJumpForward,
|
||||
onClear = onClearJumpHistory
|
||||
)
|
||||
if (showJumpHistory && hasJumpTargets) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val canGoPrevious = pageIndex > 0
|
||||
val canGoNext = pageIndex < pageCount - 1
|
||||
IconButton(onClick = onPrevious, enabled = canGoPrevious) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = "Previous page",
|
||||
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
|
||||
)
|
||||
}
|
||||
ReaderMinimalSlider(
|
||||
value = pageIndex.toFloat(),
|
||||
onValueChange = onPageScrub,
|
||||
onValueChangeFinished = onPageScrubFinished,
|
||||
valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(),
|
||||
enabled = pageCount > 1,
|
||||
activeColor = sliderActive,
|
||||
inactiveColor = sliderInactive,
|
||||
thumbColor = sliderActive,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = canGoNext) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = "Next page",
|
||||
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfZoomPercentageIndicator(
|
||||
percentage: Int,
|
||||
onResetZoomClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$percentage%",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(16.dp)
|
||||
.background(Color.White.copy(alpha = 0.5f))
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.ZoomOut,
|
||||
contentDescription = "Reset zoom",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onResetZoomClick)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfSearchTopBar(
|
||||
query: String,
|
||||
showResultsPanel: Boolean,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onToggleResults: () -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(80)
|
||||
runCatching { focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close search")
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search in PDF") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
trailingIcon = if (query.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear search")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
selectionKey = "desktop-pdf-search"
|
||||
)
|
||||
IconButton(onClick = onToggleResults, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (showResultsPanel) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = if (showResultsPanel) "Hide search results" else "Show search results"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.DesktopPdfSearchOverlay(
|
||||
isSearchActive: Boolean,
|
||||
showResultsPanel: Boolean,
|
||||
query: String,
|
||||
results: List<SharedPdfSearchResult>,
|
||||
activeSearchIndex: Int,
|
||||
highlightMode: SearchHighlightMode,
|
||||
isIndexing: Boolean,
|
||||
indexedPageCount: Int,
|
||||
pageCount: Int,
|
||||
onResultClick: (Int) -> Unit,
|
||||
onShowResults: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onToggleHighlightMode: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isSearchActive && showResultsPanel,
|
||||
enter = slideInVertically { -it } + fadeIn(),
|
||||
exit = slideOutVertically { -it } + fadeOut(),
|
||||
modifier = Modifier.fillMaxSize().zIndex(30f)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isIndexing) {
|
||||
val progress = indexedPageCount.toFloat() / pageCount.coerceAtLeast(1).toFloat()
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
"Indexing ${indexedPageCount.coerceAtMost(pageCount)}/$pageCount pages",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
query.isBlank() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Type to search this PDF", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
results.isEmpty() -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (isIndexing) "No matches in indexed pages yet" else "No matches",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
when {
|
||||
isIndexing -> "${results.size} matches so far"
|
||||
else -> "${results.size} matches"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
HorizontalDivider()
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
itemsIndexed(
|
||||
items = results,
|
||||
key = { index, result -> "${result.pageIndex}_${result.matchIndex}_$index" }
|
||||
) { index, result ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onResultClick(index) },
|
||||
color = if (index == activeSearchIndex) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
"Page ${result.pageIndex + 1}",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
result.preview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isSearchActive && !showResultsPanel && results.isNotEmpty(),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp)
|
||||
.zIndex(31f)
|
||||
) {
|
||||
DesktopPdfSearchNavigationPill(
|
||||
activeSearchIndex = activeSearchIndex,
|
||||
resultCount = results.size,
|
||||
highlightMode = highlightMode,
|
||||
onShowResults = onShowResults,
|
||||
onPrevious = onPrevious,
|
||||
onNext = onNext,
|
||||
onToggleHighlightMode = onToggleHighlightMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopPdfSearchNavigationPill(
|
||||
activeSearchIndex: Int,
|
||||
resultCount: Int,
|
||||
highlightMode: SearchHighlightMode,
|
||||
onShowResults: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onToggleHighlightMode: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
IconButton(onClick = onToggleHighlightMode, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (highlightMode == SearchHighlightMode.ALL) Icons.Default.Visibility else Icons.Default.VisibilityOff,
|
||||
contentDescription = "Toggle search highlights",
|
||||
tint = if (highlightMode == SearchHighlightMode.ALL) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPrevious, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.NavigateBefore, contentDescription = "Previous search result")
|
||||
}
|
||||
Text(
|
||||
text = if (activeSearchIndex in 0 until resultCount) {
|
||||
"${activeSearchIndex + 1}/$resultCount"
|
||||
} else {
|
||||
"$resultCount matches"
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.clickable(onClick = onShowResults).padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onNext, enabled = resultCount > 0, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.NavigateNext, contentDescription = "Next search result")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.PdfTocEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfJumpHistoryControls(
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
backPage: Int?,
|
||||
forwardPage: Int?,
|
||||
onBack: () -> Unit,
|
||||
onForward: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
val hasJumpTargets = backPage != null || forwardPage != null
|
||||
AnimatedVisibility(
|
||||
visible = visible && hasJumpTargets,
|
||||
enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(),
|
||||
exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut(),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
enabled = backPage != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Jump back",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
backPage?.let { "P. ${it + 1}" } ?: "",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onClear,
|
||||
modifier = Modifier.weight(0.8f)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Clear jump history",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Clear", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onForward,
|
||||
enabled = forwardPage != null,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
forwardPage?.let { "P. ${it + 1}" } ?: "",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = "Jump forward",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopPdfTocParentIndices(toc: List<PdfTocEntry>): Set<Int> {
|
||||
return toc.indices.filter { index ->
|
||||
val next = toc.getOrNull(index + 1)
|
||||
next != null && next.nestLevel > toc[index].nestLevel
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
internal fun desktopPdfTocAncestorIndices(
|
||||
toc: List<PdfTocEntry>,
|
||||
originalIndex: Int
|
||||
): Set<Int> {
|
||||
val targetDepth = toc.getOrNull(originalIndex)?.nestLevel ?: return emptySet()
|
||||
val ancestors = mutableSetOf<Int>()
|
||||
var currentDepth = targetDepth
|
||||
for (index in originalIndex downTo 0) {
|
||||
val entry = toc[index]
|
||||
if (entry.nestLevel < currentDepth) {
|
||||
ancestors += index
|
||||
currentDepth = entry.nestLevel
|
||||
}
|
||||
if (currentDepth == 0) break
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
internal fun desktopVisiblePdfTocEntries(
|
||||
toc: List<PdfTocEntry>,
|
||||
expandedIndices: Set<Int>
|
||||
): List<Pair<Int, PdfTocEntry>> {
|
||||
val result = mutableListOf<Pair<Int, PdfTocEntry>>()
|
||||
val visibilityStack = BooleanArray(50) { false }
|
||||
visibilityStack[0] = true
|
||||
|
||||
toc.forEachIndexed { index, entry ->
|
||||
val depth = entry.nestLevel.coerceIn(0, visibilityStack.lastIndex)
|
||||
if (visibilityStack[depth]) {
|
||||
result += index to entry
|
||||
if (depth + 1 < visibilityStack.size) {
|
||||
visibilityStack[depth + 1] = index in expandedIndices
|
||||
}
|
||||
} else if (depth + 1 < visibilityStack.size) {
|
||||
visibilityStack[depth + 1] = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfTocTreeItem(
|
||||
entry: PdfTocEntry,
|
||||
selected: Boolean,
|
||||
hasChildren: Boolean,
|
||||
isExpanded: Boolean,
|
||||
onToggleExpand: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 46.dp)
|
||||
.padding(start = (entry.nestLevel.coerceAtLeast(0) * 14).dp)
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clickable(enabled = hasChildren) { onToggleExpand() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasChildren) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
entry.title,
|
||||
fontWeight = if (selected) FontWeight.Bold else if (entry.nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
"p. ${entry.pageIndex + 1}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfNavigationEmpty(message: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfThumbnailTile(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val documentHandleId = document.handleId
|
||||
var thumbnail by remember(documentHandleId, pageIndex) { mutableStateOf<DesktopPdfPageRender?>(null) }
|
||||
var renderFailed by remember(documentHandleId, pageIndex) { mutableStateOf(false) }
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex)
|
||||
val thumbnailScale = remember(pageSize) {
|
||||
val width = pageSize?.width?.coerceAtLeast(1f) ?: 612f
|
||||
(120f / width).coerceIn(0.08f, 0.35f)
|
||||
}
|
||||
|
||||
LaunchedEffect(documentHandleId, pageIndex, thumbnailScale) {
|
||||
thumbnail = null
|
||||
renderFailed = false
|
||||
val rendered = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
DesktopPdfium.renderPage(
|
||||
document = document,
|
||||
pageIndex = pageIndex,
|
||||
scale = thumbnailScale,
|
||||
renderAnnotations = false
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
thumbnail = rendered
|
||||
renderFailed = rendered == null
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier.aspectRatio(0.707f).clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = BorderStroke(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
val render = thumbnail
|
||||
if (render != null) {
|
||||
Image(
|
||||
bitmap = render.image,
|
||||
contentDescription = "Page ${pageIndex + 1}",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().padding(3.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (renderFailed) "!" else "${pageIndex + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${pageIndex + 1}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.background(Color.Black.copy(alpha = 0.58f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfPageScrubOverlay(
|
||||
pageIndex: Int?,
|
||||
pageCount: Int
|
||||
) {
|
||||
if (pageIndex == null || pageCount <= 0) return
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
tonalElevation = 6.dp,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = "Page ${pageIndex + 1} of $pageCount",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.PdfNormalizedPoint
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.PdfSelectionGeometry
|
||||
import com.aryan.reader.shared.pdf.PdfTextCharBounds
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfInkRenderer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfTextDraft
|
||||
import com.aryan.reader.shared.ui.sharedPdfHitTest
|
||||
import com.aryan.reader.shared.ui.toSharedPdfPoint
|
||||
|
||||
internal val PdfInkTool.isDesktopHighlighter: Boolean
|
||||
get() = this == PdfInkTool.HIGHLIGHTER || this == PdfInkTool.HIGHLIGHTER_ROUND
|
||||
|
||||
internal val SharedPdfAnnotation.isDesktopTextSelectionHighlight: Boolean
|
||||
get() = kind == PdfAnnotationKind.HIGHLIGHT &&
|
||||
text.isNotBlank() &&
|
||||
rangeStartIndex != null &&
|
||||
rangeEndIndex != null
|
||||
|
||||
internal fun List<PdfPagePoint>.withDesktopPdfDragPoint(
|
||||
point: Offset,
|
||||
canvasSize: IntSize,
|
||||
tool: PdfInkTool,
|
||||
snapHighlighter: Boolean,
|
||||
timestamp: Long
|
||||
): List<PdfPagePoint> {
|
||||
val nextPoint = point.toSharedPdfPoint(canvasSize, timestamp)
|
||||
if (snapHighlighter && tool.isDesktopHighlighter && isNotEmpty()) {
|
||||
val pageAspectRatio = canvasSize.width.toFloat() / canvasSize.height.coerceAtLeast(1).toFloat()
|
||||
return listOf(
|
||||
first(),
|
||||
SharedPdfInkRenderer.calculateSnappedPoint(
|
||||
currentPoint = nextPoint,
|
||||
startPoint = first(),
|
||||
pageAspectRatio = pageAspectRatio
|
||||
)
|
||||
)
|
||||
}
|
||||
return this + nextPoint
|
||||
}
|
||||
|
||||
internal data class DesktopPdfCharHit(
|
||||
val index: Int,
|
||||
val source: String,
|
||||
val point: Offset,
|
||||
val normalized: PdfNormalizedPoint
|
||||
)
|
||||
|
||||
internal fun SharedPdfAnnotation.toDesktopPdfTextSelection(): DesktopPdfTextSelection {
|
||||
return DesktopPdfTextSelection(
|
||||
text = text,
|
||||
lineBounds = boundsList.ifEmpty { listOfNotNull(bounds) },
|
||||
startIndex = rangeStartIndex ?: 0,
|
||||
endIndex = rangeEndIndex ?: text.length
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfDocument.linkAt(
|
||||
pageIndex: Int,
|
||||
point: Offset,
|
||||
canvasSize: IntSize
|
||||
): DesktopPdfLinkTarget? {
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||
return DesktopPdfium.linkAt(
|
||||
document = this,
|
||||
pageIndex = pageIndex,
|
||||
normalizedX = point.x / canvasSize.width,
|
||||
normalizedY = point.y / canvasSize.height,
|
||||
viewportWidth = canvasSize.width,
|
||||
viewportHeight = canvasSize.height
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfDocument.charHitAt(
|
||||
pageIndex: Int,
|
||||
point: Offset,
|
||||
canvasSize: IntSize
|
||||
): DesktopPdfCharHit? {
|
||||
val normalized = PdfSelectionGeometry.normalizedPoint(
|
||||
pointX = point.x,
|
||||
pointY = point.y,
|
||||
viewportWidth = canvasSize.width,
|
||||
viewportHeight = canvasSize.height
|
||||
) ?: return null
|
||||
val nativeIndex = DesktopPdfium.charIndexAt(
|
||||
document = this,
|
||||
pageIndex = pageIndex,
|
||||
normalizedX = normalized.x,
|
||||
normalizedY = normalized.y,
|
||||
viewportWidth = canvasSize.width,
|
||||
viewportHeight = canvasSize.height
|
||||
)
|
||||
if (nativeIndex != null) {
|
||||
return DesktopPdfCharHit(
|
||||
index = nativeIndex,
|
||||
source = "native",
|
||||
point = point,
|
||||
normalized = normalized
|
||||
)
|
||||
}
|
||||
val fallback = PdfSelectionGeometry.nearestCharOnLine(
|
||||
chars = textPageData(pageIndex).chars.visiblePdfTextBounds(),
|
||||
point = normalized
|
||||
) ?: return null
|
||||
return DesktopPdfCharHit(
|
||||
index = fallback.index,
|
||||
source = "fallback_line",
|
||||
point = point,
|
||||
normalized = normalized
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfDocument.wordSelectionAt(
|
||||
pageIndex: Int,
|
||||
point: Offset,
|
||||
canvasSize: IntSize
|
||||
): DesktopPdfTextSelection? {
|
||||
val hit = charHitAt(pageIndex, point, canvasSize) ?: return null
|
||||
if (hit.source == "fallback_line" && !isPointNearTextChar(pageIndex, hit.index, hit.normalized)) {
|
||||
return null
|
||||
}
|
||||
val pageText = textPageData(pageIndex).text
|
||||
if (pageText.isEmpty()) return null
|
||||
val hitIndex = hit.index.coerceIn(0, pageText.lastIndex)
|
||||
if (!pageText[hitIndex].isDesktopPdfWordPart()) return null
|
||||
var startIndex = hitIndex
|
||||
while (startIndex > 0 && pageText[startIndex - 1].isDesktopPdfWordPart()) {
|
||||
startIndex -= 1
|
||||
}
|
||||
var endIndex = hitIndex
|
||||
while (endIndex < pageText.lastIndex && pageText[endIndex + 1].isDesktopPdfWordPart()) {
|
||||
endIndex += 1
|
||||
}
|
||||
return selectionBetweenIndexes(
|
||||
pageIndex = pageIndex,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
canvasSize = canvasSize,
|
||||
useNativeBounds = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun DesktopPdfDocument.isPointNearTextChar(
|
||||
pageIndex: Int,
|
||||
charIndex: Int,
|
||||
point: PdfNormalizedPoint
|
||||
): Boolean {
|
||||
val charBounds = textPageData(pageIndex).chars
|
||||
.visiblePdfTextBounds()
|
||||
.firstOrNull { it.index == charIndex }
|
||||
?: return false
|
||||
val horizontalPadding = maxOf((charBounds.right - charBounds.left) * 2f, 0.025f)
|
||||
val verticalPadding = maxOf((charBounds.bottom - charBounds.top) * 0.65f, 0.006f)
|
||||
return point.x in (charBounds.left - horizontalPadding)..(charBounds.right + horizontalPadding) &&
|
||||
point.y in (charBounds.top - verticalPadding)..(charBounds.bottom + verticalPadding)
|
||||
}
|
||||
|
||||
private fun Char.isDesktopPdfWordPart(): Boolean {
|
||||
return isLetterOrDigit() || this == '\'' || this == '-' || this == '_'
|
||||
}
|
||||
|
||||
internal fun DesktopPdfDocument.selectionPreviewBetweenIndexes(
|
||||
pageIndex: Int,
|
||||
startIndex: Int,
|
||||
endIndex: Int,
|
||||
canvasSize: IntSize
|
||||
): DesktopPdfTextSelection? {
|
||||
return selectionBetweenIndexes(
|
||||
pageIndex = pageIndex,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
canvasSize = canvasSize,
|
||||
useNativeBounds = false,
|
||||
includeText = false
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfDocument.selectionBetweenIndexes(
|
||||
pageIndex: Int,
|
||||
startIndex: Int,
|
||||
endIndex: Int,
|
||||
canvasSize: IntSize,
|
||||
useNativeBounds: Boolean = true,
|
||||
includeText: Boolean = true
|
||||
): DesktopPdfTextSelection? {
|
||||
val chars = textPageData(pageIndex).chars
|
||||
if (chars.isEmpty()) return null
|
||||
val firstIndex = minOf(startIndex, endIndex)
|
||||
val lastIndex = maxOf(startIndex, endIndex)
|
||||
val selectedChars = chars.filter { it.index in firstIndex..lastIndex }
|
||||
if (selectedChars.isEmpty()) return null
|
||||
val text = if (includeText) {
|
||||
selectedChars.joinToString("") { it.char.toString() }
|
||||
.replace(DesktopPdfSelectionInlineWhitespaceRegex, " ")
|
||||
.replace(DesktopPdfSelectionBlankLinesRegex, "\n\n")
|
||||
.trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (includeText && text.isBlank()) return null
|
||||
val fallbackBounds = PdfSelectionGeometry.lineBoundsForChars(selectedChars.visiblePdfTextBounds())
|
||||
if (!includeText && fallbackBounds.isEmpty()) return null
|
||||
val nativeBounds = if (useNativeBounds) {
|
||||
DesktopPdfium.textRectsForRange(
|
||||
document = this,
|
||||
pageIndex = pageIndex,
|
||||
startIndex = firstIndex,
|
||||
endIndex = lastIndex,
|
||||
viewportWidth = canvasSize.width,
|
||||
viewportHeight = canvasSize.height
|
||||
).map { it.toPdfPageBounds() }
|
||||
.filter { it.right > it.left && it.bottom > it.top }
|
||||
.mergePdfBoundsByLine()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return DesktopPdfTextSelection(
|
||||
text = text,
|
||||
lineBounds = nativeBounds.ifEmpty { fallbackBounds },
|
||||
startIndex = firstIndex,
|
||||
endIndex = lastIndex
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfTextRect.toPdfPageBounds(): PdfPageBounds {
|
||||
return PdfPageBounds(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom
|
||||
)
|
||||
}
|
||||
|
||||
internal fun SharedPdfAnnotation.toRenderablePdfAnnotations(
|
||||
document: DesktopPdfDocument,
|
||||
pageIndex: Int,
|
||||
canvasSize: IntSize
|
||||
): List<SharedPdfAnnotation> {
|
||||
val startIndex = rangeStartIndex
|
||||
val endIndex = rangeEndIndex
|
||||
if (kind != PdfAnnotationKind.HIGHLIGHT || startIndex == null || endIndex == null) {
|
||||
return listOf(this)
|
||||
}
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) {
|
||||
return listOf(this)
|
||||
}
|
||||
val dynamicBounds = DesktopPdfium.textRectsForRange(
|
||||
document = document,
|
||||
pageIndex = pageIndex,
|
||||
startIndex = startIndex,
|
||||
endIndex = endIndex,
|
||||
viewportWidth = canvasSize.width,
|
||||
viewportHeight = canvasSize.height
|
||||
).map { it.toPdfPageBounds() }
|
||||
.filter { it.right > it.left && it.bottom > it.top }
|
||||
.mergePdfBoundsByLine()
|
||||
|
||||
return dynamicBounds.ifEmpty { boundsList.ifEmpty { listOfNotNull(bounds) } }
|
||||
.mapIndexed { index, dynamicBounds ->
|
||||
copy(
|
||||
id = "${id}_line_$index",
|
||||
bounds = dynamicBounds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SharedPdfTextDraft.containsOffset(
|
||||
pageIndex: Int,
|
||||
offset: Offset,
|
||||
canvasSize: IntSize
|
||||
): Boolean {
|
||||
if (this.pageIndex != pageIndex || canvasSize.width <= 0 || canvasSize.height <= 0) return false
|
||||
val left = bounds.left * canvasSize.width
|
||||
val right = bounds.right * canvasSize.width
|
||||
val top = bounds.top * canvasSize.height
|
||||
val bottom = bounds.bottom * canvasSize.height
|
||||
return offset.x in left..right && offset.y in top..bottom
|
||||
}
|
||||
|
||||
internal fun List<SharedPdfAnnotation>.textAnnotationHitAt(
|
||||
pageIndex: Int,
|
||||
point: Offset,
|
||||
canvasSize: IntSize
|
||||
): SharedPdfAnnotation? {
|
||||
return asReversed().firstOrNull { annotation ->
|
||||
annotation.kind == PdfAnnotationKind.TEXT &&
|
||||
annotation.pageIndex == pageIndex &&
|
||||
annotation.sharedPdfHitTest(point, canvasSize)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<PdfPageBounds>.mergePdfBoundsByLine(): List<PdfPageBounds> {
|
||||
return PdfSelectionGeometry.mergeBoundsByLine(this)
|
||||
}
|
||||
|
||||
private fun List<DesktopPdfTextChar>.visiblePdfTextBounds(): List<PdfTextCharBounds> {
|
||||
return asSequence()
|
||||
.filter { it.hasBounds && !it.char.isISOControl() }
|
||||
.map { it.toPdfTextCharBounds() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun DesktopPdfTextChar.toPdfTextCharBounds(): PdfTextCharBounds {
|
||||
return PdfTextCharBounds(
|
||||
index = index,
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom
|
||||
)
|
||||
}
|
||||
|
||||
internal const val DesktopPdfSelectionPreviewThrottleMillis = 32L
|
||||
internal const val DesktopPdfZoomCommitDebounceMillis = 180L
|
||||
internal const val DesktopPdfZoomRenderDebounceMillis = 300L
|
||||
internal const val DesktopPdfViewportPersistDebounceMillis = 300L
|
||||
internal const val DesktopPdfPaginationPrefetchDelayMillis = 450L
|
||||
internal const val DesktopPdfRenderScaleTolerance = 0.01f
|
||||
internal const val DesktopPdfPaginationRenderCacheRadius = 2
|
||||
private val DesktopPdfSelectionInlineWhitespaceRegex = Regex("[ \\t\\x0B\\f\\r]+")
|
||||
private val DesktopPdfSelectionBlankLinesRegex = Regex("\\n{3,}")
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import com.aryan.reader.shared.ui.SharedHsvColorPickerDialog
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuRect
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuSize
|
||||
import com.aryan.reader.shared.ui.SharedSelectionMenuViewport
|
||||
import com.aryan.reader.shared.ui.sharedSelectionMenuPlacement
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class DesktopPdfTextSelection(
|
||||
val text: String,
|
||||
val lineBounds: List<PdfPageBounds>,
|
||||
val startIndex: Int,
|
||||
val endIndex: Int
|
||||
)
|
||||
|
||||
private data class DesktopPdfSelectionCanvasBounds(
|
||||
val left: Float,
|
||||
val top: Float,
|
||||
val right: Float,
|
||||
val bottom: Float
|
||||
) {
|
||||
val centerX: Float get() = (left + right) / 2f
|
||||
}
|
||||
|
||||
private fun DesktopPdfTextSelection.canvasBounds(canvasSize: IntSize): DesktopPdfSelectionCanvasBounds? {
|
||||
val validBounds = lineBounds.filter { it.right > it.left && it.bottom > it.top }
|
||||
if (validBounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||
return DesktopPdfSelectionCanvasBounds(
|
||||
left = validBounds.minOf { it.left } * canvasSize.width,
|
||||
top = validBounds.minOf { it.top } * canvasSize.height,
|
||||
right = validBounds.maxOf { it.right } * canvasSize.width,
|
||||
bottom = validBounds.maxOf { it.bottom } * canvasSize.height
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfTextSelection.menuAnchor(
|
||||
canvasSize: IntSize,
|
||||
fallback: Offset?
|
||||
): Offset {
|
||||
val bounds = canvasBounds(canvasSize) ?: return fallback ?: Offset.Zero
|
||||
return Offset(x = bounds.centerX, y = bounds.top)
|
||||
}
|
||||
|
||||
private fun DesktopPdfTextSelection.startHandleOffset(canvasSize: IntSize): Offset? {
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||
val first = lineBounds.firstOrNull { it.right > it.left && it.bottom > it.top } ?: return null
|
||||
return Offset(
|
||||
x = first.left * canvasSize.width,
|
||||
y = first.bottom * canvasSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private fun DesktopPdfTextSelection.endHandleOffset(canvasSize: IntSize): Offset? {
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return null
|
||||
val last = lineBounds.lastOrNull { it.right > it.left && it.bottom > it.top } ?: return null
|
||||
return Offset(
|
||||
x = last.right * canvasSize.width,
|
||||
y = last.bottom * canvasSize.height
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DesktopPdfTextSelection.handleAt(
|
||||
point: Offset,
|
||||
canvasSize: IntSize
|
||||
): DesktopPdfSelectionHandle? {
|
||||
val start = startHandleOffset(canvasSize)
|
||||
val end = endHandleOffset(canvasSize)
|
||||
|
||||
fun Offset.containsHandlePoint(): Boolean {
|
||||
val halfWidth = DesktopPdfSelectionHandleTouchWidthPx / 2f
|
||||
return point.x in (x - halfWidth)..(x + halfWidth) &&
|
||||
point.y in (y - DesktopPdfSelectionHandleTouchTopPx)..(y + DesktopPdfSelectionHandleTouchBottomPx)
|
||||
}
|
||||
|
||||
return when {
|
||||
start != null && start.containsHandlePoint() -> DesktopPdfSelectionHandle.START
|
||||
end != null && end.containsHandlePoint() -> DesktopPdfSelectionHandle.END
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfSearchHighlightOverlay(
|
||||
bounds: List<PdfPageBounds>,
|
||||
canvasSize: IntSize,
|
||||
color: Color
|
||||
) {
|
||||
if (bounds.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
bounds.forEach { rect ->
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
(rect.right - rect.left) * canvasSize.width,
|
||||
(rect.bottom - rect.top) * canvasSize.height
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTextSelectionOverlay(
|
||||
selection: DesktopPdfTextSelection?,
|
||||
canvasSize: IntSize
|
||||
) {
|
||||
val bounds = selection?.lineBounds.orEmpty()
|
||||
if (bounds.isEmpty()) return
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
bounds.forEach { rect ->
|
||||
drawRect(
|
||||
color = Color(0x663B82F6),
|
||||
topLeft = Offset(rect.left * canvasSize.width, rect.top * canvasSize.height),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
(rect.right - rect.left) * canvasSize.width,
|
||||
(rect.bottom - rect.top) * canvasSize.height
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfTextSelectionHandles(
|
||||
selection: DesktopPdfTextSelection?,
|
||||
canvasSize: IntSize,
|
||||
activeHandle: DesktopPdfSelectionHandle?
|
||||
) {
|
||||
selection ?: return
|
||||
if (canvasSize.width <= 0 || canvasSize.height <= 0) return
|
||||
val density = LocalDensity.current
|
||||
val handleSize = 24.dp
|
||||
val handleWidthPx = with(density) { handleSize.toPx() }
|
||||
val start = selection.startHandleOffset(canvasSize)
|
||||
val end = selection.endHandleOffset(canvasSize)
|
||||
val handleColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
fun Modifier.handleOffset(position: Offset): Modifier = offset {
|
||||
IntOffset(
|
||||
x = (position.x - handleWidthPx / 2f).roundToInt(),
|
||||
y = position.y.roundToInt()
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
start?.let { position ->
|
||||
Icon(
|
||||
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
|
||||
contentDescription = "Selection start handle",
|
||||
tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.END) 0.72f else 1f),
|
||||
modifier = Modifier
|
||||
.handleOffset(position)
|
||||
.size(handleSize)
|
||||
.graphicsLayer {
|
||||
rotationZ = 30f
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
}
|
||||
)
|
||||
}
|
||||
end?.let { position ->
|
||||
Icon(
|
||||
imageVector = DesktopPdfSelectionMenuIcons.Teardrop,
|
||||
contentDescription = "Selection end handle",
|
||||
tint = handleColor.copy(alpha = if (activeHandle == DesktopPdfSelectionHandle.START) 0.72f else 1f),
|
||||
modifier = Modifier
|
||||
.handleOffset(position)
|
||||
.size(handleSize)
|
||||
.graphicsLayer {
|
||||
rotationZ = -30f
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopPdfSelectionMenuIcons {
|
||||
val Copy = vector(
|
||||
name = "DesktopPdfSelectionCopy",
|
||||
pathData = "M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z"
|
||||
)
|
||||
val Dictionary = vector(
|
||||
name = "DesktopPdfSelectionDictionary",
|
||||
pathData = "M160,569L205,569L228,503L332,503L356,569L400,569L303,311L257,311L160,569ZM241,466L279,359L281,359L319,466L241,466ZM560,396L560,328Q593,314 627.5,307Q662,300 700,300Q726,300 751,304Q776,308 800,314L800,378Q776,369 751.5,364.5Q727,360 700,360Q662,360 627,369.5Q592,379 560,396ZM560,616L560,548Q593,534 627.5,527Q662,520 700,520Q726,520 751,524Q776,528 800,534L800,598Q776,589 751.5,584.5Q727,580 700,580Q662,580 627,589Q592,598 560,616ZM560,506L560,438Q593,424 627.5,417Q662,410 700,410Q726,410 751,414Q776,418 800,424L800,488Q776,479 751.5,474.5Q727,470 700,470Q662,470 627,479.5Q592,489 560,506ZM260,640Q307,640 351.5,650.5Q396,661 440,682L440,288Q399,264 353,252Q307,240 260,240Q224,240 188.5,247Q153,254 120,268Q120,268 120,268Q120,268 120,268L120,664Q120,664 120,664Q120,664 120,664Q155,652 189.5,646Q224,640 260,640ZM520,682Q564,661 608.5,650.5Q653,640 700,640Q736,640 770.5,646Q805,652 840,664Q840,664 840,664Q840,664 840,664L840,268Q840,268 840,268Q840,268 840,268Q807,254 771.5,247Q736,240 700,240Q653,240 607,252Q561,264 520,288L520,682ZM480,800Q432,762 376,741Q320,720 260,720Q218,720 177.5,731Q137,742 100,762Q79,773 59.5,761Q40,749 40,726L40,244Q40,233 45.5,223Q51,213 62,208Q108,184 158,172Q208,160 260,160Q318,160 373.5,175Q429,190 480,220Q531,190 586.5,175Q642,160 700,160Q752,160 802,172Q852,184 898,208Q909,213 914.5,223Q920,233 920,244L920,726Q920,749 900.5,761Q881,773 860,762Q823,742 782.5,731Q742,720 700,720Q640,720 584,741Q528,762 480,800ZM280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461Q280,461 280,461L280,461Q280,461 280,461Q280,461 280,461Z"
|
||||
)
|
||||
val Search = vector(
|
||||
name = "DesktopPdfSelectionSearch",
|
||||
pathData = "M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z"
|
||||
)
|
||||
val Teardrop = vector(
|
||||
name = "DesktopPdfSelectionTeardrop",
|
||||
pathData = "M480,860Q347,860 253.5,768Q160,676 160,544Q160,481 184.5,423.5Q209,366 254,322L480,100L706,322Q751,366 775.5,423.5Q800,481 800,544Q800,676 706.5,768Q613,860 480,860Z"
|
||||
)
|
||||
|
||||
private fun vector(name: String, pathData: String): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = name,
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 960f,
|
||||
viewportHeight = 960f
|
||||
).apply {
|
||||
addPath(
|
||||
pathData = PathParser().parsePathString(pathData).toNodes(),
|
||||
fill = SolidColor(Color.Black)
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class DesktopPdfSelectionHandle {
|
||||
START,
|
||||
END
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PdfSelectionMenu(
|
||||
selection: DesktopPdfTextSelection?,
|
||||
menuOffset: Offset?,
|
||||
canvasSize: IntSize,
|
||||
highlighterPalette: List<Int> = SharedPdfHighlighterPalette.defaultColors,
|
||||
onHighlighterPaletteChange: (SharedPdfHighlighterPalette) -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onHighlight: (Int) -> Unit,
|
||||
onSearch: () -> Unit,
|
||||
onDefine: () -> Unit,
|
||||
onSpeak: () -> Unit,
|
||||
showDefine: Boolean,
|
||||
showSpeak: Boolean,
|
||||
showSearch: Boolean,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
selection ?: return
|
||||
val anchor = menuOffset ?: return
|
||||
val selectionBounds = selection.canvasBounds(canvasSize)
|
||||
val paletteColors = remember(highlighterPalette) {
|
||||
SharedPdfAndroidHighlightColors.palette
|
||||
}
|
||||
var editingHighlighterSlot by remember(selection.startIndex, selection.endIndex, paletteColors) {
|
||||
mutableStateOf<Int?>(null)
|
||||
}
|
||||
val actions = buildList {
|
||||
add(PdfSelectionMenuAction("Copy", DesktopPdfSelectionMenuIcons.Copy, onCopy))
|
||||
if (showDefine) add(PdfSelectionMenuAction("Define", DesktopPdfSelectionMenuIcons.Dictionary, onDefine))
|
||||
if (showSpeak) add(PdfSelectionMenuAction("Speak", Icons.AutoMirrored.Filled.VolumeUp, onSpeak))
|
||||
if (showSearch) add(PdfSelectionMenuAction("Search", DesktopPdfSelectionMenuIcons.Search, onSearch))
|
||||
add(PdfSelectionMenuAction("Clear", Icons.Default.Close, onClear, isDestructive = true))
|
||||
}
|
||||
val estimatedHeight = PdfSelectionMenuPaletteHeightPx +
|
||||
(((actions.size + 2) / 3).coerceAtLeast(1) * PdfSelectionMenuActionRowHeightPx)
|
||||
val placement = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(canvasSize.width, canvasSize.height),
|
||||
popup = SharedSelectionMenuSize(
|
||||
width = PdfSelectionMenuWidthPx.roundToInt(),
|
||||
height = estimatedHeight.roundToInt()
|
||||
),
|
||||
selection = if (selectionBounds != null) {
|
||||
SharedSelectionMenuRect(
|
||||
left = selectionBounds.left,
|
||||
top = selectionBounds.top,
|
||||
right = selectionBounds.right,
|
||||
bottom = selectionBounds.bottom
|
||||
)
|
||||
} else {
|
||||
SharedSelectionMenuRect(
|
||||
left = anchor.x,
|
||||
top = anchor.y,
|
||||
right = anchor.x,
|
||||
bottom = anchor.y
|
||||
)
|
||||
},
|
||||
marginPx = PdfSelectionMenuMarginPx,
|
||||
gapPx = PdfSelectionMenuAnchorGapPx
|
||||
)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 10.dp,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(placement.x, placement.y)
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 180.dp, max = 220.dp)
|
||||
.padding(bottom = 6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
paletteColors.forEach { colorArgb ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(28.dp)
|
||||
.clickable { onHighlight(colorArgb) },
|
||||
color = Color(colorArgb),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.28f)),
|
||||
content = {}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(28.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.sweepGradient(
|
||||
listOf(
|
||||
Color.Red,
|
||||
Color.Yellow,
|
||||
Color.Green,
|
||||
Color.Cyan,
|
||||
Color.Blue,
|
||||
Color.Magenta,
|
||||
Color.Red
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), RoundedCornerShape(16.dp))
|
||||
.clickable { editingHighlighterSlot = 0 }
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
actions.chunked(3).forEach { rowActions ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
rowActions.forEach { action ->
|
||||
val tint = if (action.isDestructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(58.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { action.onClick() }
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = action.icon,
|
||||
contentDescription = action.label,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
action.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
repeat(3 - rowActions.size) {
|
||||
Spacer(modifier = Modifier.width(58.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
editingHighlighterSlot?.let { requestedSlot ->
|
||||
val slot = requestedSlot.coerceIn(0, paletteColors.lastIndex)
|
||||
val initialColor = Color(paletteColors[slot]).copy(alpha = 1f)
|
||||
SharedHsvColorPickerDialog(
|
||||
initialColor = initialColor,
|
||||
title = "Highlight color ${slot + 1}",
|
||||
onDismiss = { editingHighlighterSlot = null },
|
||||
onSave = { color ->
|
||||
onHighlighterPaletteChange(
|
||||
SharedPdfHighlighterPalette(paletteColors).withColorAt(
|
||||
slotIndex = slot,
|
||||
colorArgb = color.copy(alpha = SharedPdfHighlighterPalette.DefaultAlpha / 255f).toArgb()
|
||||
)
|
||||
)
|
||||
editingHighlighterSlot = null
|
||||
}
|
||||
) { liveColor ->
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
paletteColors.forEachIndexed { index, argb ->
|
||||
val color = if (index == slot) liveColor else Color(argb).copy(alpha = 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(color)
|
||||
.border(
|
||||
width = if (index == slot) 3.dp else 1.dp,
|
||||
color = if (index == slot) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f),
|
||||
shape = RoundedCornerShape(21.dp)
|
||||
)
|
||||
.clickable { editingHighlighterSlot = index },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
color = if (color.luminance() > 0.5f) Color.Black else Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PdfSelectionMenuAction(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
val isDestructive: Boolean = false
|
||||
)
|
||||
|
||||
private const val PdfSelectionMenuWidthPx = 220f
|
||||
private const val PdfSelectionMenuPaletteHeightPx = 54f
|
||||
private const val PdfSelectionMenuActionRowHeightPx = 66f
|
||||
private const val PdfSelectionMenuAnchorGapPx = 16f
|
||||
private const val PdfSelectionMenuMarginPx = 6f
|
||||
private const val DesktopPdfSelectionHandleTouchWidthPx = 44f
|
||||
private const val DesktopPdfSelectionHandleTouchTopPx = 8f
|
||||
private const val DesktopPdfSelectionHandleTouchBottomPx = 40f
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
|
||||
private const val DesktopPdfSearchIndexHeader = "EpistemePdfSearchIndex\t1"
|
||||
|
||||
internal fun desktopPdfAnnotationFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_$safeName.json")
|
||||
}
|
||||
|
||||
internal fun desktopPdfBookmarkFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_bookmarks.json")
|
||||
}
|
||||
|
||||
internal fun desktopPdfRichTextFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserDataRoot(), "annotations/pdf_${safeName}_rich_text.json")
|
||||
}
|
||||
|
||||
internal fun desktopPdfSearchIndexFile(documentPath: String): File {
|
||||
val safeName = documentPath.hashCode().toString().replace("-", "n")
|
||||
return File(desktopUserCacheRoot(), "search/pdf_${safeName}_text_index.tsv")
|
||||
}
|
||||
|
||||
internal fun restoreDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File): Int {
|
||||
val sourceFile = File(document.path)
|
||||
val lines = runCatching { indexFile.readLines(Charsets.UTF_8) }.getOrNull() ?: return document.indexedSearchTextPageCount()
|
||||
if (lines.firstOrNull() != DesktopPdfSearchIndexHeader) return 0
|
||||
val metadata = lines
|
||||
.asSequence()
|
||||
.drop(1)
|
||||
.takeWhile { !it.startsWith("page\t") }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 2)
|
||||
if (parts.size == 2) parts[0] to parts[1] else null
|
||||
}
|
||||
.toMap()
|
||||
val isFresh = metadata["pathHash"] == document.path.hashCode().toString() &&
|
||||
metadata["fileSize"] == sourceFile.length().toString() &&
|
||||
metadata["lastModified"] == sourceFile.lastModified().toString() &&
|
||||
metadata["pageCount"] == document.pageCount.toString()
|
||||
if (!isFresh) return 0
|
||||
|
||||
val decoder = Base64.getDecoder()
|
||||
lines.asSequence()
|
||||
.filter { it.startsWith("page\t") }
|
||||
.forEach { line ->
|
||||
val parts = line.split('\t', limit = 3)
|
||||
val pageIndex = parts.getOrNull(1)?.toIntOrNull() ?: return@forEach
|
||||
val text = runCatching {
|
||||
String(decoder.decode(parts.getOrNull(2).orEmpty()), Charsets.UTF_8)
|
||||
}.getOrDefault("")
|
||||
document.cacheSearchTextPage(pageIndex, text)
|
||||
}
|
||||
return document.indexedSearchTextPageCount()
|
||||
}
|
||||
|
||||
internal fun saveDesktopPdfSearchIndex(document: DesktopPdfDocument, indexFile: File) {
|
||||
val sourceFile = File(document.path)
|
||||
val pages = document.indexedSearchPages()
|
||||
if (pages.isEmpty()) return
|
||||
val encoder = Base64.getEncoder()
|
||||
val payload = buildString {
|
||||
appendLine(DesktopPdfSearchIndexHeader)
|
||||
appendLine("pathHash\t${document.path.hashCode()}")
|
||||
appendLine("fileSize\t${sourceFile.length()}")
|
||||
appendLine("lastModified\t${sourceFile.lastModified()}")
|
||||
appendLine("pageCount\t${document.pageCount}")
|
||||
pages.forEach { page ->
|
||||
append("page\t")
|
||||
append(page.pageIndex)
|
||||
append('\t')
|
||||
appendLine(encoder.encodeToString(page.text.toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
indexFile.parentFile?.mkdirs()
|
||||
indexFile.writeText(payload, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.gestures.calculateCentroid
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.isCtrlPressed as isPointerCtrlPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val DesktopPdfZoomGestureFrameMillis = 16L
|
||||
internal const val DesktopPdfPaginationFastFirstRenderMaxScale = 2.0f
|
||||
|
||||
internal fun desktopPdfScrollZoomFactor(scrollDelta: Float): Float {
|
||||
if (!scrollDelta.isFinite() || abs(scrollDelta) < 0.01f) return 1f
|
||||
val normalizedDelta = scrollDelta.coerceIn(-8f, 8f)
|
||||
return exp((-normalizedDelta * 0.12f).toDouble()).toFloat()
|
||||
}
|
||||
|
||||
internal fun desktopPdfZoomTarget(
|
||||
currentZoom: Float,
|
||||
zoomSpec: PdfZoomSpec,
|
||||
factor: Float
|
||||
): Float {
|
||||
val baseZoom = currentZoom.takeIf { it.isFinite() } ?: zoomSpec.default
|
||||
val safeFactor = factor.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
return zoomSpec.clamp(baseZoom * safeFactor)
|
||||
}
|
||||
|
||||
internal fun desktopPdfAnchoredScrollTarget(
|
||||
currentScroll: Int,
|
||||
anchor: Float,
|
||||
oldZoom: Float,
|
||||
newZoom: Float
|
||||
): Int {
|
||||
if (
|
||||
!anchor.isFinite() ||
|
||||
!oldZoom.isFinite() ||
|
||||
!newZoom.isFinite() ||
|
||||
oldZoom <= 0f ||
|
||||
newZoom <= 0f
|
||||
) {
|
||||
return currentScroll.coerceAtLeast(0)
|
||||
}
|
||||
val zoomRatio = newZoom / oldZoom
|
||||
return (((currentScroll + anchor) * zoomRatio) - anchor).roundToInt().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
internal fun desktopPdfAnchoredLazyItemScrollOffset(
|
||||
itemOffset: Int,
|
||||
anchor: Float,
|
||||
oldZoom: Float,
|
||||
newZoom: Float
|
||||
): Int {
|
||||
if (
|
||||
!anchor.isFinite() ||
|
||||
!oldZoom.isFinite() ||
|
||||
!newZoom.isFinite() ||
|
||||
oldZoom <= 0f ||
|
||||
newZoom <= 0f
|
||||
) {
|
||||
return (-itemOffset).coerceAtLeast(0)
|
||||
}
|
||||
val zoomRatio = newZoom / oldZoom
|
||||
val offsetWithinItem = anchor - itemOffset
|
||||
return ((offsetWithinItem * zoomRatio) - anchor).roundToInt().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
internal fun desktopPdfAnchoredPageScrollDelta(
|
||||
viewportRootOffset: Offset,
|
||||
oldPageRootOffset: Offset,
|
||||
currentPageRootOffset: Offset,
|
||||
anchor: Offset,
|
||||
oldZoom: Float,
|
||||
newZoom: Float
|
||||
): IntOffset? {
|
||||
if (
|
||||
!anchor.x.isFinite() ||
|
||||
!anchor.y.isFinite() ||
|
||||
!oldZoom.isFinite() ||
|
||||
!newZoom.isFinite() ||
|
||||
oldZoom <= 0f ||
|
||||
newZoom <= 0f
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val rootAnchor = viewportRootOffset + anchor
|
||||
val oldPageLocal = rootAnchor - oldPageRootOffset
|
||||
val zoomRatio = newZoom / oldZoom
|
||||
val newPageLocal = Offset(oldPageLocal.x * zoomRatio, oldPageLocal.y * zoomRatio)
|
||||
val desiredPageRoot = rootAnchor - newPageLocal
|
||||
val delta = currentPageRootOffset - desiredPageRoot
|
||||
return IntOffset(delta.x.roundToInt(), delta.y.roundToInt())
|
||||
}
|
||||
|
||||
internal fun desktopPdfPaginationFirstRenderScale(
|
||||
requestedScale: Float,
|
||||
hasPageRender: Boolean,
|
||||
isOpeningRender: Boolean = false
|
||||
): Float {
|
||||
if (hasPageRender || isOpeningRender || !requestedScale.isFinite() || requestedScale <= 0f) {
|
||||
return requestedScale
|
||||
}
|
||||
return requestedScale.coerceAtMost(DesktopPdfPaginationFastFirstRenderMaxScale)
|
||||
}
|
||||
|
||||
internal data class DesktopPdfZoomPreview(
|
||||
val baseZoom: Float,
|
||||
val zoom: Float,
|
||||
val anchor: Offset?,
|
||||
val displayMode: PdfDisplayMode,
|
||||
val pageIndex: Int?
|
||||
)
|
||||
|
||||
internal data class DesktopPdfCachedPageRender(
|
||||
val render: DesktopPdfPageRender,
|
||||
val scale: Float
|
||||
)
|
||||
|
||||
internal fun desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset,
|
||||
anchor: Offset,
|
||||
pageCanvasSize: IntSize
|
||||
): Offset? {
|
||||
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return null
|
||||
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null
|
||||
val pageAnchor = viewportRootOffset + anchor - pageRootOffset
|
||||
if (!pageAnchor.x.isFinite() || !pageAnchor.y.isFinite()) return null
|
||||
return Offset(
|
||||
x = (pageAnchor.x / pageCanvasSize.width).coerceIn(0f, 1f),
|
||||
y = (pageAnchor.y / pageCanvasSize.height).coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopPdfDocumentZoomPreviewTranslation(
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset,
|
||||
anchor: Offset,
|
||||
previewScale: Float
|
||||
): Offset? {
|
||||
if (!anchor.x.isFinite() || !anchor.y.isFinite()) return null
|
||||
if (!previewScale.isFinite() || previewScale <= 0f) return null
|
||||
val rootAnchor = viewportRootOffset + anchor
|
||||
if (!rootAnchor.x.isFinite() || !rootAnchor.y.isFinite()) return null
|
||||
return Offset(
|
||||
x = (pageRootOffset.x - rootAnchor.x) * (previewScale - 1f),
|
||||
y = (pageRootOffset.y - rootAnchor.y) * (previewScale - 1f)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun Modifier.desktopPdfZoomPreviewLayer(
|
||||
preview: DesktopPdfZoomPreview?,
|
||||
currentZoom: Float,
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset,
|
||||
pageCanvasSize: IntSize
|
||||
): Modifier {
|
||||
val activePreview = preview ?: return this
|
||||
if (pageCanvasSize.width <= 0 || pageCanvasSize.height <= 0) return this
|
||||
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||
val previewScale = activePreview.zoom / currentZoom
|
||||
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||
val transformOrigin = activePreview.anchor?.let { anchor ->
|
||||
desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset = viewportRootOffset,
|
||||
pageRootOffset = pageRootOffset,
|
||||
anchor = anchor,
|
||||
pageCanvasSize = pageCanvasSize
|
||||
)?.let { pivot ->
|
||||
TransformOrigin(pivotFractionX = pivot.x, pivotFractionY = pivot.y)
|
||||
} ?: TransformOrigin.Center
|
||||
} ?: TransformOrigin.Center
|
||||
return graphicsLayer {
|
||||
scaleX = previewScale
|
||||
scaleY = previewScale
|
||||
this.transformOrigin = transformOrigin
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Modifier.desktopPdfDocumentZoomPreviewLayer(
|
||||
preview: DesktopPdfZoomPreview?,
|
||||
currentZoom: Float,
|
||||
viewportRootOffset: Offset,
|
||||
pageRootOffset: Offset
|
||||
): Modifier {
|
||||
val activePreview = preview ?: return this
|
||||
if (!currentZoom.isFinite() || currentZoom <= 0f) return this
|
||||
if (!activePreview.zoom.isFinite() || activePreview.zoom <= 0f) return this
|
||||
val previewScale = activePreview.zoom / currentZoom
|
||||
if (!previewScale.isFinite() || abs(previewScale - 1f) < 0.0001f) return this
|
||||
val translation = activePreview.anchor?.let { anchor ->
|
||||
desktopPdfDocumentZoomPreviewTranslation(
|
||||
viewportRootOffset = viewportRootOffset,
|
||||
pageRootOffset = pageRootOffset,
|
||||
anchor = anchor,
|
||||
previewScale = previewScale
|
||||
)
|
||||
} ?: Offset.Zero
|
||||
return graphicsLayer {
|
||||
scaleX = previewScale
|
||||
scaleY = previewScale
|
||||
translationX = translation.x
|
||||
translationY = translation.y
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Modifier.desktopPdfZoomGestures(
|
||||
currentZoom: Float,
|
||||
zoomSpec: PdfZoomSpec,
|
||||
onZoomChanged: (oldZoom: Float, newZoom: Float, anchor: Offset?) -> Unit
|
||||
): Modifier {
|
||||
val latestZoom by rememberUpdatedState(currentZoom)
|
||||
val latestOnZoomChanged by rememberUpdatedState(onZoomChanged)
|
||||
return this.pointerInput(zoomSpec) {
|
||||
var gestureZoom = latestZoom
|
||||
var appliedGestureZoom = latestZoom
|
||||
var lastZoomEventAt = 0L
|
||||
var lastAppliedZoomAt = 0L
|
||||
fun applyZoomFactor(factor: Float, eventTime: Long, anchor: Offset?) {
|
||||
if (lastZoomEventAt == 0L || eventTime - lastZoomEventAt > 180L) {
|
||||
gestureZoom = latestZoom
|
||||
appliedGestureZoom = latestZoom
|
||||
lastAppliedZoomAt = 0L
|
||||
}
|
||||
val newZoom = desktopPdfZoomTarget(gestureZoom, zoomSpec, factor)
|
||||
gestureZoom = newZoom
|
||||
lastZoomEventAt = eventTime
|
||||
val shouldApplyNow = lastAppliedZoomAt == 0L ||
|
||||
eventTime - lastAppliedZoomAt >= DesktopPdfZoomGestureFrameMillis ||
|
||||
newZoom == zoomSpec.min ||
|
||||
newZoom == zoomSpec.max
|
||||
if (shouldApplyNow && newZoom != appliedGestureZoom) {
|
||||
latestOnZoomChanged(appliedGestureZoom, newZoom, anchor)
|
||||
appliedGestureZoom = newZoom
|
||||
lastAppliedZoomAt = eventTime
|
||||
}
|
||||
}
|
||||
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val eventTime = event.changes.maxOfOrNull { it.uptimeMillis } ?: 0L
|
||||
if (event.type == PointerEventType.Scroll && event.keyboardModifiers.isPointerCtrlPressed) {
|
||||
val scrollDelta = event.changes.fold(Offset.Zero) { total, change ->
|
||||
total + change.scrollDelta
|
||||
}
|
||||
val zoomDelta = if (abs(scrollDelta.y) >= abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x
|
||||
val factor = desktopPdfScrollZoomFactor(zoomDelta)
|
||||
if (abs(factor - 1f) > 0.0001f) {
|
||||
applyZoomFactor(factor, eventTime, event.changes.firstOrNull()?.position)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
val pressedPointers = event.changes.count { it.pressed }
|
||||
if (pressedPointers > 1) {
|
||||
val zoomChange = event.calculateZoom()
|
||||
if (zoomChange.isFinite() && abs(zoomChange - 1f) > 0.005f) {
|
||||
val centroid = event.calculateCentroid(useCurrent = false)
|
||||
val anchor = if (centroid == Offset.Unspecified) {
|
||||
event.changes.firstOrNull { it.pressed }?.position
|
||||
} else {
|
||||
centroid
|
||||
}
|
||||
applyZoomFactor(zoomChange, eventTime, anchor)
|
||||
}
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
private const val PdfZoomPerfLogTag = "EpistemePdfZoomPerf"
|
||||
private const val PdfLinkLogTag = "EpistemePdfLink"
|
||||
private const val EpubLinkLogTag = "EpistemeEpubLink"
|
||||
private const val EpubPaginationLogTag = "EpistemeEpubPagination"
|
||||
private const val ReaderGapLogTag = "EpistemeReaderGap"
|
||||
private const val EpubSelectionDebugLogTag = "EPUB_SELECTION_DEBUG"
|
||||
|
||||
internal fun logPdfSelection(message: String) {
|
||||
}
|
||||
|
||||
internal fun logPdfZoomPerf(message: String) {
|
||||
logDesktopDiagnostic(PdfZoomPerfLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logPdfZoomPerf(message: () -> String) {
|
||||
logDesktopDiagnostic(PdfZoomPerfLogTag, message)
|
||||
}
|
||||
|
||||
internal fun logPdfLink(message: String) {
|
||||
logDesktopDiagnostic(PdfLinkLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logEpubLink(message: String) {
|
||||
logDesktopDiagnostic(EpubLinkLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logEpubPagination(message: String) {
|
||||
logDesktopDiagnostic(EpubPaginationLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logReaderGap(message: String) {
|
||||
logDesktopDiagnostic(ReaderGapLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun logEpubSelectionDebug(message: String) {
|
||||
logDesktopDiagnostic(EpubSelectionDebugLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun DesktopPdfLinkTarget.formatLogTarget(): String {
|
||||
return "dest=${destPageIndex?.let { it + 1 } ?: "null"} uri=\"${uri.orEmpty().logPreview()}\""
|
||||
}
|
||||
|
||||
internal fun Float.formatLogFloat(): String {
|
||||
return String.format("%.3f", this)
|
||||
}
|
||||
|
||||
internal fun Offset?.formatLogOffset(): String {
|
||||
if (this == null) return "none"
|
||||
return "${x.formatLogFloat()},${y.formatLogFloat()}"
|
||||
}
|
||||
|
||||
internal fun IntSize.formatLogSize(): String {
|
||||
return "${width}x${height}"
|
||||
}
|
||||
|
||||
internal fun DesktopPdfCharHit?.formatLogHit(prefix: String): String {
|
||||
if (this == null) {
|
||||
return "${prefix}Index=null ${prefix}Source=none ${prefix}X=null ${prefix}Y=null ${prefix}Nx=null ${prefix}Ny=null"
|
||||
}
|
||||
return "${prefix}Index=$index ${prefix}Source=$source " +
|
||||
"${prefix}X=${point.x.formatLogFloat()} ${prefix}Y=${point.y.formatLogFloat()} " +
|
||||
"${prefix}Nx=${normalized.x.formatLogFloat()} ${prefix}Ny=${normalized.y.formatLogFloat()}"
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.reader.ReaderSessionState
|
||||
import com.aryan.reader.shared.ui.SharedAppTab
|
||||
|
||||
internal data class DesktopReaderOpening(
|
||||
val requestId: Long,
|
||||
val bookId: String,
|
||||
val title: String,
|
||||
val formatLabel: String,
|
||||
val returnTab: SharedAppTab
|
||||
)
|
||||
|
||||
internal sealed interface DesktopReaderOpenResult {
|
||||
val opening: DesktopReaderOpening
|
||||
val book: BookItem
|
||||
|
||||
data class Pdf(
|
||||
override val opening: DesktopReaderOpening,
|
||||
override val book: BookItem,
|
||||
val document: DesktopPdfDocument
|
||||
) : DesktopReaderOpenResult
|
||||
|
||||
data class Text(
|
||||
override val opening: DesktopReaderOpening,
|
||||
override val book: BookItem,
|
||||
val session: ReaderSessionState
|
||||
) : DesktopReaderOpenResult
|
||||
|
||||
data class Failure(
|
||||
override val opening: DesktopReaderOpening,
|
||||
override val book: BookItem,
|
||||
val message: String
|
||||
) : DesktopReaderOpenResult
|
||||
}
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL
|
||||
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.ReaderAiFeature
|
||||
import com.aryan.reader.shared.ReaderAiModelOption
|
||||
import com.aryan.reader.shared.ReaderAiModelOptions
|
||||
import com.aryan.reader.shared.ReaderAiResultState
|
||||
import com.aryan.reader.shared.ReaderAutoScrollState
|
||||
import com.aryan.reader.shared.ReaderCloudTtsVoices
|
||||
import com.aryan.reader.shared.ReaderExtrasState
|
||||
import com.aryan.reader.shared.ReaderExternalLookupAction
|
||||
import com.aryan.reader.shared.ReaderTtsReadScope
|
||||
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
|
||||
import com.aryan.reader.shared.maskedReaderAiKey
|
||||
import com.aryan.reader.shared.ui.SharedMarkdownText
|
||||
import com.aryan.reader.shared.ui.SharedReaderPopupLayer
|
||||
import com.aryan.reader.shared.ui.SharedReaderTtsReplacementControls
|
||||
import com.aryan.reader.shared.ui.SharedStableOutlinedTextField
|
||||
import com.aryan.reader.shared.ui.sharedReaderPopupWidth
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderBottomSheet(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
SharedReaderPopupLayer(onDismiss = onDismiss) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(40f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.24f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss
|
||||
)
|
||||
)
|
||||
val sheetHorizontalPadding = 24.dp
|
||||
val sheetAvailableWidth = (maxWidth - sheetHorizontalPadding - sheetHorizontalPadding).coerceAtLeast(0.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = sheetHorizontalPadding, vertical = 16.dp)
|
||||
.width(sharedReaderPopupWidth(sheetAvailableWidth))
|
||||
.heightIn(max = 560.dp),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp, bottomStart = 10.dp, bottomEnd = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.width(42.dp)
|
||||
.height(4.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(999.dp))
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopReaderAiResultSheet(
|
||||
result: ReaderAiResultState,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
DesktopReaderBottomSheet(
|
||||
title = result.title ?: "AI",
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
val errorMessage = result.errorMessage
|
||||
when {
|
||||
result.isLoading -> Text("Working...", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
errorMessage != null -> Text(errorMessage, color = MaterialTheme.colorScheme.error)
|
||||
else -> SharedMarkdownText(result.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopAiByokSettingsDialog(
|
||||
settings: ReaderAiByokSettings,
|
||||
secureStorageAvailable: Boolean,
|
||||
onSettingsChange: (ReaderAiByokSettings) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sanitized = settings.sanitized()
|
||||
var selectedProvider by remember { mutableStateOf("gemini") }
|
||||
var pendingKey by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("AI keys and models") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 640.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (!secureStorageAvailable) {
|
||||
Text(
|
||||
"Secure key storage is unavailable on this operating system. Keys entered here will be used for this session but will not be persisted.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Text("Saved keys", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
DesktopSavedAiKeyRow(
|
||||
label = "Gemini",
|
||||
keyValue = sanitized.geminiKey,
|
||||
onClear = { onSettingsChange(sanitized.copy(geminiKey = "", ttsModel = "")) }
|
||||
)
|
||||
DesktopSavedAiKeyRow(
|
||||
label = "Groq",
|
||||
keyValue = sanitized.groqKey,
|
||||
onClear = { onSettingsChange(sanitized.copy(groqKey = "")) }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text("Add or replace key", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
listOf("gemini" to "Gemini", "groq" to "Groq").forEach { (provider, label) ->
|
||||
FilterChip(
|
||||
selected = selectedProvider == provider,
|
||||
onClick = { selectedProvider = provider },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedStableOutlinedTextField(
|
||||
value = pendingKey,
|
||||
onValueChange = { pendingKey = it },
|
||||
label = { Text("API key") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
TextButton(
|
||||
enabled = pendingKey.isNotBlank(),
|
||||
onClick = {
|
||||
val trimmed = pendingKey.trim()
|
||||
val next = when (selectedProvider) {
|
||||
"gemini" -> sanitized.copy(
|
||||
geminiKey = trimmed,
|
||||
ttsModel = sanitized.ttsModel.ifBlank { GEMINI_CLOUD_TTS_MODEL_ID }
|
||||
)
|
||||
"groq" -> sanitized.copy(groqKey = trimmed)
|
||||
else -> sanitized
|
||||
}
|
||||
onSettingsChange(next)
|
||||
pendingKey = ""
|
||||
},
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Save key")
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Show AI in reader", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Matches the Android hide toggle for smart dictionary, summaries, and recaps.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = !sanitized.hideReaderAiFeatures,
|
||||
onCheckedChange = { enabled ->
|
||||
onSettingsChange(sanitized.copy(hideReaderAiFeatures = !enabled))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Use one model for all features", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Turn this off to choose separate models per reader AI feature.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = sanitized.useOneModel,
|
||||
onCheckedChange = { onSettingsChange(sanitized.copy(useOneModel = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (sanitized.useOneModel) {
|
||||
DesktopAiModelSelector(
|
||||
title = "All AI features",
|
||||
description = "Smart dictionary, summaries, and recaps all use this model.",
|
||||
selectedId = sanitized.modelForAll,
|
||||
onSelected = { onSettingsChange(sanitized.copy(modelForAll = it)) }
|
||||
)
|
||||
} else {
|
||||
DesktopAiModelSelector(
|
||||
title = "Smart dictionary",
|
||||
description = "Used when defining selected words or phrases.",
|
||||
selectedId = sanitized.defineModel,
|
||||
onSelected = { onSettingsChange(sanitized.copy(defineModel = it)) }
|
||||
)
|
||||
DesktopAiModelSelector(
|
||||
title = "Summaries",
|
||||
description = "Used for EPUB summaries and PDF page summaries.",
|
||||
selectedId = sanitized.summarizeModel,
|
||||
onSelected = { onSettingsChange(sanitized.copy(summarizeModel = it)) }
|
||||
)
|
||||
DesktopAiModelSelector(
|
||||
title = "Recaps",
|
||||
description = "Used for story recap generation.",
|
||||
selectedId = sanitized.recapModel,
|
||||
onSelected = { onSettingsChange(sanitized.copy(recapModel = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
DesktopAiModelSelector(
|
||||
title = "Cloud TTS",
|
||||
description = "Uses the saved Gemini key. Only $GEMINI_CLOUD_TTS_MODEL is supported for now.",
|
||||
selectedId = sanitized.ttsModel,
|
||||
options = listOf(ReaderAiModelOption("gemini", GEMINI_CLOUD_TTS_MODEL)),
|
||||
onSelected = { onSettingsChange(sanitized.copy(ttsModel = it)) }
|
||||
)
|
||||
Text("Cloud TTS voice", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ReaderCloudTtsVoices.chunked(3).forEach { rowVoices ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
rowVoices.forEach { voice ->
|
||||
FilterChip(
|
||||
selected = sanitized.ttsSpeakerId == voice.id,
|
||||
onClick = { onSettingsChange(sanitized.copy(ttsSpeakerId = voice.id)) },
|
||||
label = {
|
||||
Column {
|
||||
Text(voice.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
voice.description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSavedAiKeyRow(
|
||||
label: String,
|
||||
keyValue: String,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
keyValue.takeIf { it.isNotBlank() }?.let(::maskedReaderAiKey) ?: "No key saved",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
TextButton(enabled = keyValue.isNotBlank(), onClick = onClear) {
|
||||
Text("Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopAiModelSelector(
|
||||
title: String,
|
||||
description: String,
|
||||
selectedId: String,
|
||||
options: List<ReaderAiModelOption> = ReaderAiModelOptions,
|
||||
onSelected: (String) -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
FilterChip(
|
||||
selected = selectedId.isBlank(),
|
||||
onClick = { onSelected("") },
|
||||
label = { Text("No model") }
|
||||
)
|
||||
options.forEach { option ->
|
||||
FilterChip(
|
||||
selected = selectedId == option.id,
|
||||
onClick = { onSelected(option.id) },
|
||||
label = { Text(option.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPdfExtrasPanel(
|
||||
pageText: String,
|
||||
recapText: String,
|
||||
extrasState: ReaderExtrasState,
|
||||
aiByokSettings: ReaderAiByokSettings,
|
||||
externalLookupAvailable: Boolean,
|
||||
cloudTtsFeatureAvailable: Boolean,
|
||||
onExternalLookup: (ReaderExternalLookupAction, String) -> Unit,
|
||||
onAiAction: (ReaderAiFeature, String) -> Unit,
|
||||
onCloudTtsStart: (ReaderTtsReadScope) -> Unit,
|
||||
onCloudTtsPauseResume: () -> Unit,
|
||||
onCloudTtsStop: () -> Unit,
|
||||
onCloudTtsClearCache: () -> Unit,
|
||||
onAutoScrollChange: (ReaderAutoScrollState) -> Unit,
|
||||
ttsReplacementPreferences: ReaderTtsReplacementPreferences,
|
||||
ttsReplacementBookId: String,
|
||||
onTtsReplacementPreferencesChange: (ReaderTtsReplacementPreferences) -> Unit
|
||||
) {
|
||||
val settings = aiByokSettings.sanitized()
|
||||
val autoScroll = extrasState.autoScroll.sanitized()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text("Extras", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (externalLookupAvailable) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
ReaderExternalLookupAction.entries.forEach { action ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
enabled = pageText.isNotBlank(),
|
||||
onClick = { onExternalLookup(action, pageText) },
|
||||
label = { Text(action.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Auto scroll", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = autoScroll.enabled,
|
||||
onCheckedChange = { onAutoScrollChange(autoScroll.copy(enabled = it)) }
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
value = autoScroll.speed,
|
||||
onValueChange = { onAutoScrollChange(autoScroll.copy(speed = it).sanitized()) },
|
||||
valueRange = 12f..160f
|
||||
)
|
||||
val ttsBusy = extrasState.cloudTts.isLoading || extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused
|
||||
if (cloudTtsFeatureAvailable) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
when {
|
||||
extrasState.cloudTts.isLoading -> "Preparing audio"
|
||||
extrasState.cloudTts.isPaused -> "Paused"
|
||||
extrasState.cloudTts.isPlaying -> "Reading"
|
||||
settings.isCloudTtsAvailable -> "Cloud TTS ready"
|
||||
else -> "Cloud TTS needs Gemini"
|
||||
},
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
extrasState.cloudTts.errorMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
val statusMessage = extrasState.cloudTts.progress.currentPositionLabel
|
||||
?: extrasState.cloudTts.statusMessage?.takeIf { it.isNotBlank() }
|
||||
statusMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable || ttsBusy,
|
||||
onClick = {
|
||||
if (ttsBusy) {
|
||||
onCloudTtsStop()
|
||||
} else {
|
||||
onCloudTtsStart(ReaderTtsReadScope.BOOK)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(if (ttsBusy) "Stop" else "Read")
|
||||
}
|
||||
}
|
||||
if (extrasState.cloudTts.isPlaying || extrasState.cloudTts.isPaused) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(onClick = onCloudTtsPauseResume) {
|
||||
Text(if (extrasState.cloudTts.isPaused) "Resume" else "Pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||
onClick = { onCloudTtsStart(ReaderTtsReadScope.PAGE) }
|
||||
) {
|
||||
Text("Page")
|
||||
}
|
||||
TextButton(
|
||||
enabled = settings.isCloudTtsAvailable && !ttsBusy && pageText.isNotBlank(),
|
||||
onClick = { onCloudTtsStart(ReaderTtsReadScope.BOOK) }
|
||||
) {
|
||||
Text("From here")
|
||||
}
|
||||
}
|
||||
val cacheSummary = extrasState.cloudTts.cacheSummary
|
||||
if (cacheSummary.hasCachedAudio) {
|
||||
Text(
|
||||
"Cache: ${cacheSummary.currentVoiceLabel}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (cacheSummary.hasCurrentVoiceCachedAudio) {
|
||||
TextButton(onClick = onCloudTtsClearCache) {
|
||||
Text("Clear voice cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedReaderTtsReplacementControls(
|
||||
preferences = ttsReplacementPreferences,
|
||||
bookId = ttsReplacementBookId,
|
||||
onPreferencesChange = onTtsReplacementPreferencesChange
|
||||
)
|
||||
if (settings.areReaderAiFeaturesAvailable) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
TextButton(
|
||||
enabled = pageText.isNotBlank() && !extrasState.aiResult.isLoading,
|
||||
onClick = { onAiAction(ReaderAiFeature.SUMMARIZE, pageText) }
|
||||
) {
|
||||
Text("Summarize page")
|
||||
}
|
||||
TextButton(
|
||||
enabled = recapText.isNotBlank() && !extrasState.aiResult.isLoading,
|
||||
onClick = { onAiAction(ReaderAiFeature.RECAP, recapText) }
|
||||
) {
|
||||
Text("Recap")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import com.aryan.reader.shared.ReaderTexture
|
||||
import com.aryan.reader.shared.ReaderTextureFilePrefix
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
internal object DesktopReaderTextures {
|
||||
private val bytesCache = mutableMapOf<String, ByteArray?>()
|
||||
private val dataUriCache = mutableMapOf<String, String?>()
|
||||
private val imageCache = mutableMapOf<String, ImageBitmap?>()
|
||||
private val importExtensions = setOf("jpg", "jpeg", "png", "webp", "gif", "bmp")
|
||||
|
||||
fun importedTextureIds(): List<String> {
|
||||
return readerTextureDirectory()
|
||||
.listFiles { file -> file.isFile && file.extension.lowercase(Locale.ROOT) in importExtensions }
|
||||
?.sortedBy { it.name.lowercase(Locale.ROOT) }
|
||||
?.map { ReaderTextureFilePrefix + it.absolutePath }
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun importTexture(source: File): String? {
|
||||
if (!source.isFile) return null
|
||||
val extension = source.extension.lowercase(Locale.ROOT)
|
||||
.takeIf { it in importExtensions }
|
||||
?: return null
|
||||
val safeName = source.nameWithoutExtension
|
||||
.replace(Regex("[^A-Za-z0-9._-]+"), "_")
|
||||
.trim('_')
|
||||
.ifBlank { "texture" }
|
||||
val directory = readerTextureDirectory().apply { mkdirs() }
|
||||
val target = File(directory, "texture_${System.currentTimeMillis()}_$safeName.$extension")
|
||||
return runCatching {
|
||||
source.copyTo(target, overwrite = false)
|
||||
val textureId = ReaderTextureFilePrefix + target.absolutePath
|
||||
bytesCache.remove(textureId)
|
||||
dataUriCache.remove(textureId)
|
||||
imageCache.remove(textureId)
|
||||
textureId
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun dataUriFor(textureId: String): String? {
|
||||
return dataUriCache.getOrPut(textureId) {
|
||||
val bytes = bytesFor(textureId) ?: return@getOrPut null
|
||||
val extension = textureExtension(textureId)
|
||||
"data:${imageMimeTypeForExtension(extension)};base64," +
|
||||
Base64.getEncoder().encodeToString(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun imageBitmapFor(textureId: String?): ImageBitmap? {
|
||||
val id = textureId ?: return null
|
||||
return imageCache.getOrPut(id) {
|
||||
val bytes = bytesFor(id) ?: return@getOrPut null
|
||||
runCatching {
|
||||
ImageIO.read(ByteArrayInputStream(bytes))?.toComposeImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytesFor(textureId: String): ByteArray? {
|
||||
return bytesCache.getOrPut(textureId) {
|
||||
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||
File(textureId.removePrefix(ReaderTextureFilePrefix)).takeIf { it.isFile }?.readBytes()
|
||||
} else {
|
||||
val texture = ReaderTexture.entries.firstOrNull { it.id == textureId } ?: return@getOrPut null
|
||||
val classLoader = Thread.currentThread().contextClassLoader ?: DesktopReaderTextures::class.java.classLoader
|
||||
classLoader
|
||||
?.getResourceAsStream(texture.assetPath)
|
||||
?.use { it.readBytes() }
|
||||
?: DesktopReaderTextures::class.java.classLoader
|
||||
?.getResourceAsStream(texture.assetPath)
|
||||
?.use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun textureExtension(textureId: String): String {
|
||||
if (textureId.startsWith(ReaderTextureFilePrefix)) {
|
||||
return File(textureId.removePrefix(ReaderTextureFilePrefix)).extension
|
||||
}
|
||||
return ReaderTexture.entries.firstOrNull { it.id == textureId }
|
||||
?.assetPath
|
||||
?.substringAfterLast('.', "png")
|
||||
?: "png"
|
||||
}
|
||||
|
||||
private fun readerTextureDirectory(): File {
|
||||
return File(desktopUserDataRoot(), "reader_textures")
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageMimeTypeForExtension(extension: String): String {
|
||||
return when (extension.lowercase(Locale.ROOT)) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"webp" -> "image/webp"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
else -> "image/png"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.platform.Font as DesktopFont
|
||||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import java.io.File
|
||||
|
||||
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
|
||||
customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it }
|
||||
}
|
||||
return fontFamily.toComposeFontFamily()
|
||||
}
|
||||
|
||||
private fun String.toComposeFontFamily(): FontFamily {
|
||||
return when (this) {
|
||||
"Serif" -> FontFamily.Serif
|
||||
"Sans" -> FontFamily.SansSerif
|
||||
"Mono" -> FontFamily.Monospace
|
||||
else -> FontFamily.Default
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
|
||||
if (size != other.size) return false
|
||||
return indices.all { index ->
|
||||
val left = this[index]
|
||||
val right = other[index]
|
||||
left.pageIndex == right.pageIndex &&
|
||||
left.chapterIndex == right.chapterIndex &&
|
||||
left.startOffset == right.startOffset &&
|
||||
left.endOffset == right.endOffset &&
|
||||
left.text.length == right.text.length &&
|
||||
left.semanticBlocks.size == right.semanticBlocks.size
|
||||
}
|
||||
}
|
||||
|
||||
internal fun CustomFontItem.toDesktopPreviewFontFamily(): FontFamily? {
|
||||
return runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue